@de-otio/epimethian-mcp 6.7.1 → 6.9.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.
package/dist/cli/index.js CHANGED
@@ -35481,7 +35481,7 @@ async function getPage(pageId, includeBody) {
35481
35481
  async function _rawCreatePage(spaceId, title, body, parentId, clientLabel) {
35482
35482
  const cfg = await getConfig();
35483
35483
  const pageBody = normalizeBodyForSubmit(body);
35484
- const epimethianTag = `Epimethian v${"6.7.1"}`;
35484
+ const epimethianTag = `Epimethian v${"6.9.0"}`;
35485
35485
  const versionMsg = cfg.attribution && clientLabel ? `Created by ${clientLabel} (via ${epimethianTag})` : `Created by ${epimethianTag}`;
35486
35486
  const payload = {
35487
35487
  title,
@@ -35502,7 +35502,7 @@ async function _rawCreatePage(spaceId, title, body, parentId, clientLabel) {
35502
35502
  async function _rawUpdatePage(pageId, opts) {
35503
35503
  const cfg = await getConfig();
35504
35504
  const newVersion = opts.version + 1;
35505
- const epimethianTag = `Epimethian v${"6.7.1"}`;
35505
+ const epimethianTag = `Epimethian v${"6.9.0"}`;
35506
35506
  const effectiveClient = cfg.attribution ? opts.clientLabel : void 0;
35507
35507
  let versionMessage;
35508
35508
  if (opts.versionMessage && effectiveClient)
@@ -36879,6 +36879,295 @@ var init_confirmation_tokens = __esm({
36879
36879
  }
36880
36880
  });
36881
36881
 
36882
+ // src/server/batch-tokens.ts
36883
+ function clampTtlSeconds(seconds) {
36884
+ if (!Number.isFinite(seconds)) return DEFAULT_BATCH_TTL_SECONDS;
36885
+ if (seconds < TTL_MIN_SECONDS) return TTL_MIN_SECONDS;
36886
+ if (seconds > TTL_MAX_SECONDS) return TTL_MAX_SECONDS;
36887
+ return Math.floor(seconds);
36888
+ }
36889
+ function getMintLimit2() {
36890
+ const raw = process.env.EPIMETHIAN_BATCH_MINT_LIMIT;
36891
+ if (raw === void 0) return MAX_BATCH_MINTS_PER_15_MIN;
36892
+ const n = parseInt(raw, 10);
36893
+ if (!Number.isFinite(n) || n < 0) return MAX_BATCH_MINTS_PER_15_MIN;
36894
+ return n;
36895
+ }
36896
+ function emitMint2(meta) {
36897
+ for (const h of mintHandlers2) {
36898
+ try {
36899
+ h(meta);
36900
+ } catch {
36901
+ }
36902
+ }
36903
+ }
36904
+ function emitValidate2(meta) {
36905
+ for (const h of validateHandlers2) {
36906
+ try {
36907
+ h(meta);
36908
+ } catch {
36909
+ }
36910
+ }
36911
+ }
36912
+ function emitRefund(meta) {
36913
+ for (const h of refundHandlers) {
36914
+ try {
36915
+ h(meta);
36916
+ } catch {
36917
+ }
36918
+ }
36919
+ }
36920
+ function sleepUntil2(targetWallClockMs) {
36921
+ return new Promise((resolve2) => {
36922
+ const remaining = targetWallClockMs - Date.now();
36923
+ if (remaining <= 0) {
36924
+ resolve2();
36925
+ return;
36926
+ }
36927
+ setTimeout(resolve2, remaining);
36928
+ });
36929
+ }
36930
+ function pruneMintTimestamps2(now) {
36931
+ const cutoff = now - MINT_WINDOW_MS2;
36932
+ if (mintTimestamps2.length === 0) return;
36933
+ if (mintTimestamps2[0] >= cutoff) return;
36934
+ mintTimestamps2 = mintTimestamps2.filter((ts) => ts >= cutoff);
36935
+ }
36936
+ function evictOldest2() {
36937
+ let oldestKey;
36938
+ let oldestSeq = Infinity;
36939
+ for (const [k, v] of store2.entries()) {
36940
+ if (v.insertSeq < oldestSeq) {
36941
+ oldestSeq = v.insertSeq;
36942
+ oldestKey = k;
36943
+ }
36944
+ }
36945
+ if (oldestKey === void 0) return;
36946
+ const entry = store2.get(oldestKey);
36947
+ store2.delete(oldestKey);
36948
+ for (const reservationId of entry.reservations.keys()) {
36949
+ reservationIndex.delete(reservationId);
36950
+ }
36951
+ emitValidate2({
36952
+ auditId: entry.auditId,
36953
+ cloudId: entry.cloudId,
36954
+ pageId: entry.pageIdsList[0] ?? "",
36955
+ outcome: "evicted"
36956
+ });
36957
+ }
36958
+ function mintBatchToken(args) {
36959
+ if (typeof args.cloudId !== "string" || args.cloudId.length === 0) {
36960
+ throw new Error("mintBatchToken: cloudId is required and must be a non-empty string.");
36961
+ }
36962
+ if (!Array.isArray(args.pageIds) || args.pageIds.length === 0) {
36963
+ throw new Error("mintBatchToken: pageIds must be a non-empty array.");
36964
+ }
36965
+ const seen = /* @__PURE__ */ new Set();
36966
+ const dedupedPageIds = [];
36967
+ for (const id of args.pageIds) {
36968
+ if (typeof id !== "string" || id.length === 0) {
36969
+ throw new Error("mintBatchToken: every page_id must be a non-empty string.");
36970
+ }
36971
+ if (!seen.has(id)) {
36972
+ seen.add(id);
36973
+ dedupedPageIds.push(id);
36974
+ }
36975
+ }
36976
+ if (dedupedPageIds.length > MAX_PAGE_IDS_PER_BATCH) {
36977
+ throw new Error(
36978
+ `mintBatchToken: page_ids list exceeds MAX_PAGE_IDS_PER_BATCH=${MAX_PAGE_IDS_PER_BATCH}.`
36979
+ );
36980
+ }
36981
+ const ttlSeconds = clampTtlSeconds(args.ttlSeconds ?? DEFAULT_BATCH_TTL_SECONDS);
36982
+ const ttlMs = ttlSeconds * 1e3;
36983
+ const N = dedupedPageIds.length;
36984
+ const requestedMax = args.maxOperations === void 0 ? N : Math.floor(args.maxOperations);
36985
+ if (!Number.isFinite(requestedMax) || requestedMax < 1) {
36986
+ throw new Error("mintBatchToken: max_operations must be a positive integer.");
36987
+ }
36988
+ const maxOperations = Math.min(requestedMax, N * 2);
36989
+ const now = Date.now();
36990
+ pruneMintTimestamps2(now);
36991
+ const limit = getMintLimit2();
36992
+ if (limit > 0 && mintTimestamps2.length >= limit) {
36993
+ const oldest = mintTimestamps2[0];
36994
+ const waitMs = Math.max(0, oldest + MINT_WINDOW_MS2 - now);
36995
+ throw new BatchMintRateLimitedError(mintTimestamps2.length, limit, waitMs);
36996
+ }
36997
+ while (store2.size >= MAX_OUTSTANDING_BATCH_TOKENS) {
36998
+ evictOldest2();
36999
+ }
37000
+ const expiresAt = now + ttlMs;
37001
+ const auditId = (0, import_node_crypto5.randomUUID)();
37002
+ const tokenStr = `btk_${(0, import_node_crypto5.randomBytes)(32).toString("hex")}`;
37003
+ const entry = {
37004
+ auditId,
37005
+ cloudId: args.cloudId,
37006
+ pageIds: new Set(dedupedPageIds),
37007
+ pageIdsList: dedupedPageIds,
37008
+ expiresAt,
37009
+ maxOperations,
37010
+ remainingOperations: maxOperations,
37011
+ reservations: /* @__PURE__ */ new Map(),
37012
+ insertSeq: ++insertSeqCounter2
37013
+ };
37014
+ store2.set(tokenStr, entry);
37015
+ mintTimestamps2.push(now);
37016
+ emitMint2({
37017
+ auditId,
37018
+ cloudId: args.cloudId,
37019
+ pageIds: dedupedPageIds,
37020
+ ttlMs,
37021
+ maxOperations,
37022
+ expiresAt,
37023
+ outstanding: store2.size
37024
+ });
37025
+ return {
37026
+ token: tokenStr,
37027
+ auditId,
37028
+ expiresAt,
37029
+ authorisedPageIds: dedupedPageIds,
37030
+ remainingOperations: maxOperations
37031
+ };
37032
+ }
37033
+ async function validateBatchToken(args) {
37034
+ const floorTarget = Date.now() + MIN_VALIDATE_FLOOR_MS2;
37035
+ let outcome;
37036
+ let auditId;
37037
+ let result = { valid: false };
37038
+ let remainingAfter;
37039
+ const entry = store2.get(args.token);
37040
+ if (!entry) {
37041
+ outcome = "unknown";
37042
+ } else {
37043
+ auditId = entry.auditId;
37044
+ const now = Date.now();
37045
+ if (now >= entry.expiresAt) {
37046
+ store2.delete(args.token);
37047
+ for (const reservationId of entry.reservations.keys()) {
37048
+ reservationIndex.delete(reservationId);
37049
+ }
37050
+ outcome = "expired";
37051
+ } else if (entry.cloudId !== args.cloudId) {
37052
+ outcome = "cloudid_mismatch";
37053
+ } else if (!entry.pageIds.has(args.pageId)) {
37054
+ outcome = "page_id_not_authorised";
37055
+ } else if (entry.remainingOperations <= 0) {
37056
+ outcome = "exhausted";
37057
+ } else {
37058
+ entry.remainingOperations -= 1;
37059
+ remainingAfter = entry.remainingOperations;
37060
+ const reservationId = (0, import_node_crypto5.randomUUID)();
37061
+ entry.reservations.set(reservationId, {
37062
+ pageId: args.pageId,
37063
+ refunded: false
37064
+ });
37065
+ reservationIndex.set(reservationId, args.token);
37066
+ outcome = "ok";
37067
+ result = { valid: true, reservationId };
37068
+ }
37069
+ }
37070
+ emitValidate2({
37071
+ auditId,
37072
+ cloudId: args.cloudId,
37073
+ pageId: args.pageId,
37074
+ outcome,
37075
+ ...remainingAfter !== void 0 ? { remainingAfter } : {}
37076
+ });
37077
+ await sleepUntil2(floorTarget);
37078
+ return result;
37079
+ }
37080
+ function refundReservation(reservationId) {
37081
+ const tokenStr = reservationIndex.get(reservationId);
37082
+ if (tokenStr === void 0) return false;
37083
+ const entry = store2.get(tokenStr);
37084
+ if (!entry) {
37085
+ reservationIndex.delete(reservationId);
37086
+ return false;
37087
+ }
37088
+ const reservation = entry.reservations.get(reservationId);
37089
+ if (!reservation) return false;
37090
+ if (reservation.refunded) {
37091
+ emitRefund({
37092
+ auditId: entry.auditId,
37093
+ cloudId: entry.cloudId,
37094
+ pageId: reservation.pageId,
37095
+ refunded: false,
37096
+ remainingAfter: entry.remainingOperations
37097
+ });
37098
+ return false;
37099
+ }
37100
+ reservation.refunded = true;
37101
+ entry.remainingOperations += 1;
37102
+ emitRefund({
37103
+ auditId: entry.auditId,
37104
+ cloudId: entry.cloudId,
37105
+ pageId: reservation.pageId,
37106
+ refunded: true,
37107
+ remainingAfter: entry.remainingOperations
37108
+ });
37109
+ return true;
37110
+ }
37111
+ function finaliseReservation(reservationId) {
37112
+ const tokenStr = reservationIndex.get(reservationId);
37113
+ reservationIndex.delete(reservationId);
37114
+ if (tokenStr === void 0) return;
37115
+ const entry = store2.get(tokenStr);
37116
+ if (!entry) return;
37117
+ entry.reservations.delete(reservationId);
37118
+ }
37119
+ function isBatchTokenShape(token) {
37120
+ if (typeof token !== "string") return false;
37121
+ if (!token.startsWith("btk_")) return false;
37122
+ if (token.length !== "btk_".length + 64) return false;
37123
+ for (let i = 4; i < token.length; i++) {
37124
+ const c = token.charCodeAt(i);
37125
+ const isHex = c >= 48 && c <= 57 || // 0-9
37126
+ c >= 97 && c <= 102 || // a-f
37127
+ c >= 65 && c <= 70;
37128
+ if (!isHex) return false;
37129
+ }
37130
+ return true;
37131
+ }
37132
+ var import_node_crypto5, DEFAULT_BATCH_TTL_SECONDS, TTL_MIN_SECONDS, TTL_MAX_SECONDS, MAX_PAGE_IDS_PER_BATCH, MAX_OUTSTANDING_BATCH_TOKENS, MAX_BATCH_MINTS_PER_15_MIN, MINT_WINDOW_MS2, MIN_VALIDATE_FLOOR_MS2, BATCH_MINT_RATE_LIMITED, BatchMintRateLimitedError, store2, reservationIndex, mintTimestamps2, insertSeqCounter2, mintHandlers2, validateHandlers2, refundHandlers;
37133
+ var init_batch_tokens = __esm({
37134
+ "src/server/batch-tokens.ts"() {
37135
+ "use strict";
37136
+ import_node_crypto5 = require("node:crypto");
37137
+ DEFAULT_BATCH_TTL_SECONDS = 15 * 60;
37138
+ TTL_MIN_SECONDS = 60;
37139
+ TTL_MAX_SECONDS = 60 * 60;
37140
+ MAX_PAGE_IDS_PER_BATCH = 50;
37141
+ MAX_OUTSTANDING_BATCH_TOKENS = 25;
37142
+ MAX_BATCH_MINTS_PER_15_MIN = 25;
37143
+ MINT_WINDOW_MS2 = 15 * 60 * 1e3;
37144
+ MIN_VALIDATE_FLOOR_MS2 = 5;
37145
+ BATCH_MINT_RATE_LIMITED = "BATCH_MINT_RATE_LIMITED";
37146
+ BatchMintRateLimitedError = class extends Error {
37147
+ code = BATCH_MINT_RATE_LIMITED;
37148
+ current;
37149
+ limit;
37150
+ waitMs;
37151
+ constructor(current, limit, waitMs) {
37152
+ super(
37153
+ `Batch authorisation mint cap exhausted: ${current} mints in the last 15 min, limit ${limit}. Window opens again in ~${Math.ceil(waitMs / 6e4)} min. Override via EPIMETHIAN_BATCH_MINT_LIMIT (set "0" to disable).`
37154
+ );
37155
+ this.name = "BatchMintRateLimitedError";
37156
+ this.current = current;
37157
+ this.limit = limit;
37158
+ this.waitMs = waitMs;
37159
+ }
37160
+ };
37161
+ store2 = /* @__PURE__ */ new Map();
37162
+ reservationIndex = /* @__PURE__ */ new Map();
37163
+ mintTimestamps2 = [];
37164
+ insertSeqCounter2 = 0;
37165
+ mintHandlers2 = [];
37166
+ validateHandlers2 = [];
37167
+ refundHandlers = [];
37168
+ }
37169
+ });
37170
+
36882
37171
  // node_modules/mdurl/lib/decode.mjs
36883
37172
  function getDecodeCache(exclude) {
36884
37173
  let cache = decodeCache[exclude];
@@ -47426,7 +47715,7 @@ function enforceContentSafetyGuards(input) {
47426
47715
  if (oldLen > SHRINKAGE_GUARD_MIN_OLD_LEN && newLen < oldLen * SHRINKAGE_GUARD_MAX_RATIO && !confirmShrinkage) {
47427
47716
  const pct = Math.round((1 - newLen / oldLen) * 100);
47428
47717
  throw new ConverterError(
47429
- `Body would shrink from ${oldLen} to ${newLen} characters (${pct}% reduction). This may indicate accidental content loss. Re-submit with confirm_shrinkage: true to proceed, or omit replace_body to use token-aware preservation.`,
47718
+ `Body would shrink from ${oldLen} to ${newLen} characters (${pct}% reduction). This may indicate accidental content loss. Re-submit with confirm_shrinkage: true to proceed (accepted by update_page, update_page_section, and update_page_sections).`,
47430
47719
  SHRINKAGE_NOT_CONFIRMED
47431
47720
  );
47432
47721
  }
@@ -47901,6 +48190,21 @@ async function maybeConsumeConfirmToken(args) {
47901
48190
  });
47902
48191
  return outcome;
47903
48192
  }
48193
+ async function tryBatchTokenForWrite(args) {
48194
+ const { batch_token, cloudId, pageId } = args;
48195
+ if (batch_token === void 0 || cloudId === void 0 || !isBatchTokenShape(batch_token)) {
48196
+ return { batchReservationId: void 0 };
48197
+ }
48198
+ const result = await validateBatchToken({
48199
+ token: batch_token,
48200
+ cloudId,
48201
+ pageId
48202
+ });
48203
+ if (result.valid) {
48204
+ return { batchReservationId: result.reservationId };
48205
+ }
48206
+ return { batchReservationId: void 0 };
48207
+ }
47904
48208
  function formatSoftConfirmationResult(err, params) {
47905
48209
  const last8 = err.token.slice(-8);
47906
48210
  const isoExpires = new Date(err.expiresAt).toISOString();
@@ -47919,10 +48223,10 @@ Token tail: ...${last8} Expires: ${isoExpires} Audit ID: ${err.auditId}
47919
48223
  The token is single-use, bound to this exact diff and page version,
47920
48224
  and invalidated by any competing write to this page. If validation
47921
48225
  fails, mint a new one by re-calling without \`confirm_token\`.`;
47922
- const tokenInText = process.env.EPIMETHIAN_TOKEN_IN_TEXT === "true";
47923
- const finalText = tokenInText ? text2 + `
48226
+ const explicitlyHidden = process.env.EPIMETHIAN_HIDE_TOKEN_IN_TEXT === "true" || process.env.EPIMETHIAN_TOKEN_IN_TEXT === "false";
48227
+ const finalText = explicitlyHidden ? text2 : text2 + `
47924
48228
 
47925
- [FALLBACK] Full token (EPIMETHIAN_TOKEN_IN_TEXT=true): ${err.token}` : text2;
48229
+ [FALLBACK] Full token: ${err.token}`;
47926
48230
  const structuredContent = {
47927
48231
  kind: "confirmation_required",
47928
48232
  confirm_token: err.token,
@@ -48124,6 +48428,7 @@ async function safePrepareBody(input) {
48124
48428
  confirmDeletions,
48125
48429
  confirmShrinkage,
48126
48430
  confirmStructureLoss,
48431
+ fullPageBody,
48127
48432
  replaceBody,
48128
48433
  allowRawHtml,
48129
48434
  confluenceBaseUrl
@@ -48252,9 +48557,15 @@ Pick one path:
48252
48557
  assertDeletionAckMatches(confirmDeletions, deletedTokens);
48253
48558
  }
48254
48559
  if (scope !== "additive" && currentBody !== void 0) {
48560
+ let guardOld = currentBody;
48561
+ let guardNew = finalStorage;
48562
+ if (scope === "section" && fullPageBody !== void 0 && fullPageBody.includes(currentBody)) {
48563
+ guardOld = fullPageBody;
48564
+ guardNew = fullPageBody.replace(currentBody, () => finalStorage);
48565
+ }
48255
48566
  enforceContentSafetyGuards({
48256
- oldStorage: currentBody,
48257
- newStorage: finalStorage,
48567
+ oldStorage: guardOld,
48568
+ newStorage: guardNew,
48258
48569
  confirmShrinkage,
48259
48570
  confirmStructureLoss: confirmStructureLoss || replaceBody === true,
48260
48571
  // confirmDeletions (any truthy form, incl. a non-empty string[]) OR
@@ -48518,6 +48829,8 @@ async function safePrepareMultiSectionBody(input) {
48518
48829
  currentStorage,
48519
48830
  sections,
48520
48831
  confirmDeletions,
48832
+ confirmShrinkage,
48833
+ confirmStructureLoss,
48521
48834
  allowRawHtml,
48522
48835
  confluenceBaseUrl
48523
48836
  } = input;
@@ -48600,6 +48913,10 @@ async function safePrepareMultiSectionBody(input) {
48600
48913
  currentBody: loc.body,
48601
48914
  scope: "section",
48602
48915
  confirmDeletions: confirmDeletions ? true : void 0,
48916
+ confirmShrinkage,
48917
+ confirmStructureLoss,
48918
+ // Measure each section's guards page-relative (see safePrepareBody).
48919
+ fullPageBody: currentStorage,
48603
48920
  ...allowRawHtml !== void 0 ? { allowRawHtml } : {},
48604
48921
  ...confluenceBaseUrl !== void 0 ? { confluenceBaseUrl } : {}
48605
48922
  });
@@ -48658,6 +48975,7 @@ var init_safe_write = __esm({
48658
48975
  "use strict";
48659
48976
  init_confluence_client();
48660
48977
  init_confirmation_tokens();
48978
+ init_batch_tokens();
48661
48979
  init_md_to_storage();
48662
48980
  init_update_orchestrator();
48663
48981
  init_content_safety_guards();
@@ -48785,7 +49103,7 @@ async function writeCheckState(state) {
48785
49103
  const data = JSON.stringify(state, null, 2) + "\n";
48786
49104
  const tmpFile = (0, import_node_path3.join)(
48787
49105
  CONFIG_DIR2,
48788
- `.update-check.${(0, import_node_crypto5.randomBytes)(4).toString("hex")}.tmp`
49106
+ `.update-check.${(0, import_node_crypto6.randomBytes)(4).toString("hex")}.tmp`
48789
49107
  );
48790
49108
  await (0, import_promises3.writeFile)(tmpFile, data, { mode: 384 });
48791
49109
  await (0, import_promises3.rename)(tmpFile, UPDATE_CHECK_FILE);
@@ -48926,14 +49244,14 @@ async function checkForUpdates(currentVersion) {
48926
49244
  return null;
48927
49245
  }
48928
49246
  }
48929
- var import_promises3, import_node_path3, import_node_os2, import_node_crypto5, import_node_child_process2, import_node_util, execFileAsync, CONFIG_DIR2, UPDATE_CHECK_FILE, ONE_DAY_MS, NPM_REGISTRY_URL, PACKAGE_NAME;
49247
+ var import_promises3, import_node_path3, import_node_os2, import_node_crypto6, import_node_child_process2, import_node_util, execFileAsync, CONFIG_DIR2, UPDATE_CHECK_FILE, ONE_DAY_MS, NPM_REGISTRY_URL, PACKAGE_NAME;
48930
49248
  var init_update_check = __esm({
48931
49249
  "src/shared/update-check.ts"() {
48932
49250
  "use strict";
48933
49251
  import_promises3 = require("node:fs/promises");
48934
49252
  import_node_path3 = require("node:path");
48935
49253
  import_node_os2 = require("node:os");
48936
- import_node_crypto5 = require("node:crypto");
49254
+ import_node_crypto6 = require("node:crypto");
48937
49255
  import_node_child_process2 = require("node:child_process");
48938
49256
  import_node_util = require("node:util");
48939
49257
  init_safe_fs();
@@ -49022,7 +49340,7 @@ var init_client_configs = __esm({
49022
49340
  null,
49023
49341
  2
49024
49342
  ),
49025
- warning: "VS Code extension \u2264 2.1.123 does not honour elicitation requests; if write tools fail with NO_USER_RESPONSE, set `EPIMETHIAN_BYPASS_ELICITATION=true`.\n\nv6.6.2 declares an `outputSchema` on every write tool, so a spec-compliant client should now surface the soft-confirm `structuredContent` to the agent. If your version of Claude Code drops content blocks when structuredContent is present (issue #15412), set `EPIMETHIAN_TOKEN_IN_TEXT=true` as a fallback \u2014 this restores the human-readable explanation by also putting the full token in the text block."
49343
+ warning: "VS Code extension \u2264 2.1.123 does not honour elicitation requests; if write tools fail with NO_USER_RESPONSE, set `EPIMETHIAN_BYPASS_ELICITATION=true`.\n\nSoft-confirm tokens are surfaced in the text content by default (v6.7.2+) so Claude Code can read them even when it drops `structuredContent` (issue #15412). To opt out for a client that does forward `structuredContent` reliably, set `EPIMETHIAN_HIDE_TOKEN_IN_TEXT=true`."
49026
49344
  },
49027
49345
  {
49028
49346
  id: "cursor",
@@ -49948,7 +50266,7 @@ Informational:
49948
50266
  var install_agent_default;
49949
50267
  var init_install_agent = __esm({
49950
50268
  "install-agent.md"() {
49951
- install_agent_default = '# Epimethian MCP - Agent Installation Guide\n\n> This document is for AI agents installing and configuring the Epimethian MCP server on behalf of a user.\n\n## Verification\n\nThe official package is **`@de-otio/epimethian-mcp`** (scoped under `@de-otio`).\n\nDo NOT install unscoped alternatives like `epimethian-mcp` \u2014 these are unofficial and potentially unsafe.\n\n## Prerequisites\n\n- Node.js 18 or later\n- npm on PATH\n\n## Step 1: Install\n\n```bash\nnpm install -g @de-otio/epimethian-mcp\n```\n\nVerify the installation:\n\n```bash\nepimethian-mcp --version 2>/dev/null || which epimethian-mcp\n```\n\n## Step 2: Resolve the absolute path\n\nIDE-hosted agents may not resolve PATH correctly. Always use the absolute path in MCP configuration:\n\n```bash\nwhich epimethian-mcp\n```\n\nUse the output as the `command` value in the MCP config below.\n\n## Step 3: Collect configuration\n\nAsk the user for:\n1. **Profile name** \u2014 a short identifier for this Confluence instance (e.g., `globex`, `acme-corp`). Lowercase alphanumeric and hyphens only.\n2. **Confluence Cloud URL** \u2014 e.g., `https://yoursite.atlassian.net`\n3. **Email address** \u2014 the email associated with their Atlassian account\n\n## Step 4: Write MCP configuration\n\nRun `epimethian-mcp setup --profile <name> --client <client-id>` after Step 5 (credential setup) \u2014 it prints the exact config snippet for your MCP host. Supported clients: `claude-code`, `claude-desktop`, `claude-code-vscode`, `cursor`, `windsurf`, `zed`, `opencode`. Keep the fallback hand-typed examples below for cases where the CLI is unavailable.\n\nAdd the server to the user\'s MCP client config. The exact file and shape depend on the client:\n\n**Claude Code, Claude Desktop, Cursor, Windsurf, Zed** \u2014 `.mcp.json` (or the\nequivalent client-specific config). The standard `mcpServers` shape:\n\n```json\n{\n "mcpServers": {\n "confluence": {\n "command": "<absolute path from Step 2>",\n "env": {\n "CONFLUENCE_PROFILE": "<profile name from Step 3>"\n }\n }\n }\n}\n```\n\n**OpenCode** \u2014 `opencode.json` at the project root or\n`~/.config/opencode/opencode.json`. Different shape (`mcp` block, `type:\n"local"`, `command` is an array, `environment` not `env`):\n\n```jsonc\n{\n "$schema": "https://opencode.ai/config.json",\n "mcp": {\n "confluence": {\n "type": "local",\n "command": ["<absolute path from Step 2>"],\n "enabled": true,\n "environment": {\n "CONFLUENCE_PROFILE": "<profile name from Step 3>",\n "EPIMETHIAN_ALLOW_UNGATED_WRITES": "true"\n }\n }\n }\n}\n```\n\nOpenCode does not support MCP elicitation (the in-protocol confirmation\nprompts), so write tools that fire the elicitation gate fail unless\n`EPIMETHIAN_ALLOW_UNGATED_WRITES=true` is set. See "MCP client\ncompatibility" below for the trade-off.\n\n**IMPORTANT:** The only required env var is `CONFLUENCE_PROFILE`. The URL,\nemail, and API token are stored securely in the OS keychain \u2014 they should\nNOT appear in config files.\n\n## Step 5: Credential setup\n\nTell the user to run this command in their terminal:\n\n```\nepimethian-mcp setup --profile <profile name from Step 3>\n```\n\nThis interactive command will:\n1. Prompt for the Confluence URL, email, and API token (masked input)\n2. Test the connection\n3. Store all credentials securely in the OS keychain under the named profile\n\nThe API token is generated at: https://id.atlassian.com/manage-profile/security/api-tokens\n\n**Do NOT ask the user for the API token yourself.** The token must go directly from the user into the interactive setup command to avoid appearing in conversation logs.\n\n## Step 6: User must restart the MCP client\n\n**IMPORTANT:** The user must restart their MCP client (e.g., restart Claude Code, reload VS Code, restart Claude Desktop) for the new server configuration to take effect. The MCP client reads `.mcp.json` at startup and does not detect changes while running.\n\nTell the user:\n> Please restart your MCP client now to activate the Confluence tools.\n\n## Step 7: Validation\n\nAfter the user restarts, verify the server is working by listing available Confluence tools or running a simple operation like listing spaces.\n\n## Adding Additional Tenants\n\nTo add a second Confluence instance (e.g., for a different customer):\n\n1. Run `epimethian-mcp setup --profile <new-profile-name>` with the new credentials\n2. In the project that uses the new tenant, update `.mcp.json` to set `CONFLUENCE_PROFILE` to the new profile name\n3. Restart the MCP client\n\nEach VS Code window / Claude Code session uses the profile specified in its `.mcp.json`. Profiles are fully isolated \u2014 different OS keychain entries, different Confluence instances.\n\n## Managing Profiles\n\n- List all profiles: `epimethian-mcp profiles`\n- Show details: `epimethian-mcp profiles --verbose`\n- Check connection: `CONFLUENCE_PROFILE=<name> epimethian-mcp status`\n- Set read-only: `epimethian-mcp profiles --set-read-only <name>`\n- Set read-write: `epimethian-mcp profiles --set-read-write <name>`\n\n### Read-Only Mode\n\nNew profiles default to **read-only**. When read-only, all write tools are blocked and return an error. To enable writes for a profile:\n\n```bash\nepimethian-mcp profiles --set-read-write <name>\n```\n\nOr during setup: `epimethian-mcp setup --profile <name> --read-write`\n\n**Important:** Restart any running MCP servers after changing the read-only flag.\n\n### Removing a Profile\n\nTo delete a profile and its credentials, run:\n\n```bash\nepimethian-mcp profiles --remove <name> --force\n```\n\n**Agents must pass `--force`** because the command normally prompts for interactive confirmation (`Remove profile "<name>" and delete its credentials? [y/N]`), which will fail in non-TTY environments like agent shell sessions. The `--force` flag skips the confirmation prompt when stdin is not a TTY.\n\nThis command:\n1. Deletes the credential entry (URL, email, API token) from the OS keychain\n2. Removes the profile from the registry at `~/.config/epimethian-mcp/profiles.json`\n3. Writes an entry to the audit log at `~/.config/epimethian-mcp/audit.log`\n\nAfter removing a profile, also remove or update any `.mcp.json` files that reference it \u2014 otherwise the MCP server will fail to start with a missing-profile error.\n\n**Errors:**\n- If the profile name is invalid (not matching lowercase alphanumeric/hyphens, 1\u201363 chars), the command exits with code 1\n- If the profile does not exist in the keychain, the keychain deletion is silently skipped \u2014 the registry entry is still removed\n\n## Accessing This Guide Post-Install\n\nOnce installed, this guide is available locally via:\n\n```bash\nepimethian-mcp agent-guide\n```\n\nThis prints the full agent guide to stdout \u2014 no web fetch required.\n\n## Uninstallation\n\nWhen a user asks to uninstall Epimethian MCP, follow these steps:\n\n### Step 1: Check for existing profiles\n\n```bash\nepimethian-mcp profiles\n```\n\n### Step 2: Ask the user about credential cleanup\n\nIf profiles exist, ask the user:\n\n> You have Epimethian profiles configured: [list the profile names]. Would you like to delete all stored credentials before uninstalling? (This removes API tokens from your OS keychain.)\n\n### Step 3: Delete credentials (if the user agrees)\n\nFor each profile the user wants removed:\n\n```bash\nepimethian-mcp profiles --remove <name> --force\n```\n\nOr to remove all profiles:\n\n```bash\nfor name in $(epimethian-mcp profiles | grep \'^ \'); do epimethian-mcp profiles --remove "$name" --force; done\n```\n\n### Step 4: Remove MCP configuration\n\nDelete the `confluence` entry (or the tenant-specific entry like `confluence-globex`) from the project\'s `.mcp.json`.\n\n### Step 5: Uninstall the package\n\n```bash\nnpm uninstall -g @de-otio/epimethian-mcp\n```\n\n### Step 6: Restart the MCP client\n\nTell the user to restart their MCP client so it stops trying to launch the removed server.\n\n## CI/CD (No Keychain)\n\nFor environments where the OS keychain is unavailable (Docker, CI), set all three env vars directly:\n\n```json\n{\n "mcpServers": {\n "confluence": {\n "command": "<absolute path>",\n "env": {\n "CONFLUENCE_URL": "<url>",\n "CONFLUENCE_EMAIL": "<email>",\n "CONFLUENCE_API_TOKEN": "<token>"\n }\n }\n }\n}\n```\n\n**Warning:** This exposes the API token in the process environment. Use profile-based auth whenever possible.\n\n## Troubleshooting\n\nIf **npm install fails**:\n- Verify Node.js 18+ is installed: `node --version`\n- Verify npm is on PATH: `npm --version`\n- If permission errors occur, the user may need to fix their npm prefix or use a Node version manager (nvm, fnm)\n\nIf **`epimethian-mcp setup` fails**:\n- "Connection failed": Verify the Confluence URL is correct and accessible\n- "Token is invalid or expired": The user needs to generate a new API token at https://id.atlassian.com/manage-profile/security/api-tokens\n- Keychain errors on Linux: The user may need to install `libsecret` / `gnome-keyring` (`apt install libsecret-tools` or equivalent)\n\nIf **the server doesn\'t appear after restart**:\n- Verify the `.mcp.json` path is correct for the user\'s MCP client\n- Verify the `command` value is an absolute path (run `which epimethian-mcp` to confirm)\n- Check that `.mcp.json` contains valid JSON (no trailing commas, correct quoting)\n\n## Write budget (safety cap on writes)\n\nepimethian-mcp enforces two write-rate caps per server process:\n\n- **Session cap** (default 250): total writes since the server started.\n- **Rolling cap** (default 75 per 15-minute window): catches bursts.\n\nThese are local safety nets, not Confluence limits. They exist because an\nautonomous agent in a retry loop or with a bad plan can issue hundreds of writes\nvery quickly, and most users would rather have a brief pause to confirm than\ndiscover the result an hour later.\n\n### What to do when you (the agent) hit `WRITE_BUDGET_EXCEEDED`\n\n1. **Stop and check.** Was the in-progress work user-requested and going as\n planned? If unsure, ask the user before continuing.\n2. **Explain to the user, in your own words:**\n - The safety budget has been hit (which scope, current vs. limit).\n - What the budget is for: a guard against runaway agents.\n - Whether the work-in-progress is legitimate (your judgement).\n - The two ways forward: wait for the rolling window to reopen, or raise the cap.\n3. **If the user wants to raise the cap**, give them this snippet to add to the\n `env` block of the epimethian-mcp entry in their MCP config (`.mcp.json` or\n equivalent \u2014 see Step 4 above for the layout):\n\n ```json\n "EPIMETHIAN_WRITE_BUDGET_ROLLING": "200",\n "EPIMETHIAN_WRITE_BUDGET_SESSION": "1000"\n ```\n\n Set either value to `"0"` to disable that scope. **Confirm with the user\n before recommending a raise** \u2014 the budget exists precisely to create a\n pause-and-check moment. The user must restart the MCP server (re-open the\n MCP client) for changes to take effect.\n4. **If the user gets a deprecation warning** about `EPIMETHIAN_WRITE_BUDGET_HOURLY`,\n tell them to rename it to `EPIMETHIAN_WRITE_BUDGET_ROLLING` in the same\n config file. The old name still works but will be removed in version 7.\n\n### Operator-side defaults\n\n- **`EPIMETHIAN_WRITE_BUDGET_SESSION`** \u2014 default 250; set to "0" to disable.\n- **`EPIMETHIAN_WRITE_BUDGET_ROLLING`** \u2014 default 75 per 15-minute window; set to "0" to disable.\n- **`EPIMETHIAN_WRITE_BUDGET_HOURLY`** \u2014 deprecated alias for `EPIMETHIAN_WRITE_BUDGET_ROLLING`; will be removed in version 7.\n\n## Soft confirmation (clients without working elicitation)\n\nSome MCP clients (OpenCode, the Claude Code VS Code extension, and\nothers) don\'t implement the in-protocol confirmation prompt \u2014 either\nthey don\'t advertise the capability, or they advertise it and never\nhonour the request (the SDK transport silently returns\n`{action: "decline"}` without showing the user a UI). Starting in\nv6.6.0, epimethian-mcp routes those confirmations through your\nagent\'s normal chat surface instead.\n\nThe implementation evolved across v6.6.0 \u2192 v6.6.3:\n\n- **v6.6.0** introduced soft elicitation for clients that don\'t\n *advertise* the capability \u2014 token-bound, single-use, diff-bound.\n- **v6.6.1** added **fast-decline auto-detection**: if the client\n advertises elicitation but the decline arrives in <50 ms (well below\n human reaction time), the session is flagged as fake and the call\n is re-routed through the soft-confirm path automatically. Threshold\n is overridable via `EPIMETHIAN_FAST_DECLINE_THRESHOLD_MS=<10..5000>`.\n No env-var configuration needed for the Claude Code VS Code\n extension\'s "fakes elicitation" bug \u2014 it Just Works.\n- **v6.6.2** declared `outputSchema` on every mutating tool so\n spec-compliant clients are obliged to forward `structuredContent`\n (where the token lives) to the agent. Added an opt-in\n `EPIMETHIAN_TOKEN_IN_TEXT=true` fallback that appends the full\n token to `content[0].text` for clients that drop `content` blocks\n on `isError: true` results (Claude Code issues #15412 / #9962 /\n #39976).\n- **v6.6.3** swapped the `outputSchema` from `z.discriminatedUnion`\n to `z.object` so the MCP SDK\'s `normalizeObjectSchema` (which only\n accepts schemas with `.shape`) can route the structured payload\n through `validateToolOutput` without throwing `_zod` undefined\n after the write commits. Hotfix; data-integrity-critical.\n\n### What you (the agent) see\n\nWhen a destructive write is requested against a client without working\nelicitation, the tool returns an error with a confirmation token:\n\n```\nisError: true\nstructuredContent:\n {\n "kind": "confirmation_required",\n "confirm_token": "<opaque token>",\n "audit_id": "<UUID for correlation>",\n "expires_at": "<ISO timestamp>",\n "page_id": "<pageId>",\n "human_summary": "<one-line description for the user>",\n "deletion_summary": { ... numeric counts only ... } // optional\n }\ncontent[0].text:\n \u26A0\uFE0F Confirmation required (SOFT_CONFIRMATION_REQUIRED)\n\n {human_summary}\n\n Please ask the user before retrying. If approved, re-call with the\n same parameters plus "confirm_token" from structuredContent.\n\n Token tail: ...<last 8 chars> Expires: <timestamp> Audit ID: <uuid>\n\n [FALLBACK] Full token (EPIMETHIAN_TOKEN_IN_TEXT=true): <full token>\n \u2190 only present when EPIMETHIAN_TOKEN_IN_TEXT=true is set\n```\n\nThe `kind` discriminator distinguishes this `"confirmation_required"`\narm from the success arm (`"written"` or `"deleted"`) on the same\ntool. Successful writes return:\n\n```\nstructuredContent:\n { "kind": "written", "page_id": "...", "new_version": 12, ... }\n```\n\n\u2026or `"kind": "deleted"` for `delete_page`.\n\n### What to do\n\n1. STOP. Don\'t retry blindly.\n2. Show the user, in their language, what\'s about to happen \u2014 use the\n `human_summary` field from `structuredContent`, or the\n human-readable text in `content[0].text` if your client doesn\'t\n forward `structuredContent`. **Never echo the token bytes to the\n user** \u2014 the token is meant to flow agent \u2192 server, not user \u2192 eye.\n3. Ask the user explicitly. Wait for their answer.\n4. If approved: re-call the tool with the SAME parameters plus\n `confirm_token`. Read the token from `structuredContent.confirm_token`\n when available; if your client doesn\'t surface that, the token\'s\n full bytes are in the `[FALLBACK] Full token: \u2026` line of\n `content[0].text` whenever `EPIMETHIAN_TOKEN_IN_TEXT=true` is set\n on the server. The 8-character "Token tail: \u2026" line in the prose\n is for human inspection only \u2014 it is **not** the token.\n5. If denied: tell the user the operation has been cancelled.\n\n### Token semantics\n\n- Single-use: a successful retry consumes the token. Replays fail.\n- 5-minute TTL by default.\n- Invalidated by any competing write to the same page (stale).\n- Bound to the specific diff and tenant: changing the body, page version, or\n tenant invalidates the token.\n\n### Operator opt-outs\n\nThese environment variables control soft confirmation behavior:\n\n- **`EPIMETHIAN_ALLOW_UNGATED_WRITES=true`** \u2014 bypasses soft confirmation\n entirely (no prompt; useful for headless / CI). Removes the\n human-in-the-loop gate; the harness\'s tool allow-list still applies.\n- **`EPIMETHIAN_DISABLE_SOFT_CONFIRM=true`** \u2014 keeps the legacy\n `ELICITATION_REQUIRED_BUT_UNAVAILABLE` failure mode for clients without\n elicitation support.\n- **`EPIMETHIAN_SOFT_CONFIRM_TTL_MS=300000`** \u2014 override the default 5-minute\n TTL (clamped to 60 seconds minimum, 15 minutes maximum).\n- **`EPIMETHIAN_SOFT_CONFIRM_MINT_LIMIT=100`** \u2014 override the per-15-minute\n mint cap (default 100; "0" disables the cap entirely).\n\n#### v6.6.1+ fast-decline auto-detection (Claude Code VS Code et al.)\n\n- **`EPIMETHIAN_TREAT_ELICITATION_AS_UNSUPPORTED=true`** \u2014 deterministic\n counterpart to fast-decline auto-detection. Use when your client is\n known to advertise elicitation but never honour it (e.g. the Claude\n Code VS Code extension \u2264 2.1.123) and you want to skip the timing\n probe on the first call. Distinct from `EPIMETHIAN_BYPASS_ELICITATION`:\n this routes through the soft-confirmation gate; bypass removes the\n gate entirely.\n- **`EPIMETHIAN_FAST_DECLINE_THRESHOLD_MS=<10..5000>`** \u2014 override the\n fast-decline threshold (default 50 ms). Raise this if a slow MCP\n transport is mis-classifying real declines as fake.\n- **`EPIMETHIAN_DISABLE_FAST_DECLINE_DETECTION=true`** \u2014 total\n off-switch for the auto-detection; restores exactly v6.6.0 behaviour.\n\n#### v6.6.2+ structured-content fallback\n\n- **`EPIMETHIAN_TOKEN_IN_TEXT=true`** \u2014 opt-in fallback for clients that\n drop `content` blocks on `isError: true` results, or that ignore the\n `outputSchema` declaration and never surface `structuredContent`\n to the agent. When set, the soft-confirm result text appends a\n `[FALLBACK] Full token (EPIMETHIAN_TOKEN_IN_TEXT=true): <token>`\n line so the agent can still extract the token. The structured\n payload is unchanged. Trade-off: the token is visible in the agent\n transcript (the security choice v6.6.0 explicitly avoided), so use\n only when needed. Today this is required for Claude Code (the VS\n Code extension and possibly the CLI) \u2014 see the per-client matrix\n below.\n\n### Multi-process deployments\n\nTokens are process-local in-memory. If you\'re running multiple MCP server\nprocesses for one tenant (e.g. a load-balanced fleet or separate processes\nper IDE window), a soft confirmation minted by process P1 will fail validation\nin process P2 (the load balancer routes the retry to a different process).\nThis is not a bug \u2014 it\'s the safe failure mode \u2014 but it means the user needs\nto mint a new token if the retry lands on a different process.\n\n**Recommendation:** Pin a single MCP server process per agent or IDE window.\nPre-seal profiles upgraded from versions before v5.5.0 must run `epimethian-mcp\nsetup` once to acquire a sealed cloudId before soft confirmation is available.\n\n## MCP client compatibility\n\nepimethian-mcp uses MCP **elicitation** (the in-protocol confirmation\nprompt added to MCP in 2025) as the human-in-the-loop gate for destructive\noperations. Different MCP clients support elicitation differently \u2014 some\nfully, some not at all, and some advertise the capability without honouring\nit. The compatibility matrix below tells you which env-var workaround to\nrecommend, if any.\n\n| Client | Elicitation? | What to do (v6.6.3+) |\n|---|---|---|\n| **Claude Code (CLI)** | Yes \u2014 full support | No special config needed. |\n| **Claude Desktop** | Yes \u2014 full support | No special config needed. |\n| **Claude Code VS Code extension** (all versions tested through 2.1.x) | Fakes it (advertises capability, never honours) | v6.6.1\'s fast-decline auto-detection routes the call through soft-confirm automatically. **Plus** set `EPIMETHIAN_TOKEN_IN_TEXT=true` in the server\'s env so the agent can read the full token from `content[0].text` \u2014 Claude Code does not currently surface `structuredContent` on `isError: true` responses (issue #15412). No `EPIMETHIAN_BYPASS_ELICITATION` needed; the gate works through the soft-confirm token flow. |\n| **OpenCode** | No \u2014 capability not advertised | v6.6.0+ soft-confirmation token flow is automatic when the client lacks elicitation. The Vercel AI SDK forwards `structuredContent` to the model when `outputSchema` is declared (which v6.6.2 added) \u2014 the agent should be able to read `confirm_token` directly. If your build of OpenCode/AI-SDK doesn\'t honour `outputSchema`, set `EPIMETHIAN_TOKEN_IN_TEXT=true` as a fallback. `EPIMETHIAN_ALLOW_UNGATED_WRITES=true` remains an escape hatch for headless / CI runs where no human is in the loop. |\n| **Cursor / Windsurf / Zed / others** | Varies | If write tools fail with `ELICITATION_REQUIRED_BUT_UNAVAILABLE`, the client doesn\'t advertise the capability \u2014 soft-confirm should kick in automatically; use `EPIMETHIAN_ALLOW_UNGATED_WRITES=true` only if no human is in the loop. If write tools succeed at the gate but the agent can\'t see the token in the response, set `EPIMETHIAN_TOKEN_IN_TEXT=true`. If the client *advertises* elicitation but always fails fast (decline arrives in <50 ms), v6.6.1\'s auto-detection re-routes through soft-confirm. Set `EPIMETHIAN_TREAT_ELICITATION_AS_UNSUPPORTED=true` to skip the timing probe on the first call when the client is known to fake it. |\n\n### Three configuration paths \u2014 pick the one that matches your client\n\nThese flags are **not interchangeable**. The newer (v6.6.x) flags\npreserve the human-in-the-loop gate; the older bypass flags remove it.\nDefault to preserving the gate.\n\n- **(Recommended) Soft-confirm token flow.** No env-var bypass needed.\n v6.6.0 + v6.6.1 + v6.6.3 give you a working soft-confirm round-trip\n for any client whose host can put a "do you approve?" prompt in front\n of the user (chat surface, tool-result UI, etc.). For clients with\n rendering quirks, layer on `EPIMETHIAN_TOKEN_IN_TEXT=true`\n (additive \u2014 keeps the gate, just exposes the token in\n `content[0].text` too).\n- **`EPIMETHIAN_ALLOW_UNGATED_WRITES=true`** \u2014 for clients that *don\'t\n advertise* elicitation AND have no other way to surface a\n confirmation prompt (e.g. headless / CI runs). Removes the gate\n entirely; only the harness allow-list and server-side guards remain.\n Pre-v6.6.0 escape hatch; rarely needed today.\n- **`EPIMETHIAN_BYPASS_ELICITATION=true`** \u2014 unconditional bypass,\n regardless of whether the client advertises elicitation. Original\n v6.4.1 escape hatch for Claude Code VS Code\'s "fakes elicitation"\n bug. **In v6.6.1+ this is no longer needed** for that bug \u2014 the\n fast-decline detector routes around it. Use only if you specifically\n want to remove the gate.\n\n### Trade-off: what you give up by setting `ALLOW_UNGATED_WRITES` or `BYPASS_ELICITATION`\n\nThese two flags **disable the human-in-the-loop confirmation gate\nentirely**. Writes still go through the harness\'s tool allow-list (so\nusers can still block the tool in their permission settings) and\nthrough every server-side guard (provenance, source-policy,\nwrite-budget, byte-equivalence) \u2014 but the user no longer gets a\nprompt before each destructive operation. Recommend this only when:\n\n1. The user is aware of and accepts the trade-off, AND\n2. The user\'s MCP client provides some other interaction model where they\n can intervene (e.g. they review tool calls before approval), OR\n3. The work is read-mostly and only occasional, additive writes happen.\n\n**Do NOT set either flag silently.** If you (the agent) need to recommend\none, explain to the user what the gate is for, why their client can\'t\nhonour it, and what alternative protections remain.\n\n`EPIMETHIAN_TREAT_ELICITATION_AS_UNSUPPORTED` and\n`EPIMETHIAN_TOKEN_IN_TEXT` (v6.6.1+ / v6.6.2+) are different \u2014 they\n**preserve** the gate by routing through the soft-confirm token flow.\nThey affect how the prompt reaches the user, not whether one happens.\nPrefer these to the bypass flags whenever the client supports any\nform of agent \u2194 user dialogue.\n\n## Other operator-side environment variables\n\nThese are off by default and only relevant in specific scenarios:\n\n- **`EPIMETHIAN_SUPPRESS_EQUIVALENT_DELETIONS`** \u2014 opt-in (default OFF).\n When set to `true`, suppresses the `confirm_deletions` gate for token\n deletion+creation pairs that canonicalise to byte-equivalent XML\n (e.g. re-rendering the same `<ac:link>` macros with different attribute\n order, or regenerating an `<ac:structured-macro>` whose parameters and\n CDATA body are identical after sort). Genuine semantic deletions still\n fire the gate. Every suppressed pair is recorded in the mutation log\n for postmortem. Useful for spaces with lots of cross-link rewrites\n where the gate fires repeatedly on no-op churn.\n- **`EPIMETHIAN_REQUIRE_SOURCE`** \u2014 opt-in (default OFF). When `true`,\n every write tool call must include a `source` parameter (one of\n `user_request` / `file_or_cli_input` / `chained_tool_output` /\n `elicitation_response`). Calls without an explicit source are rejected\n with `SOURCE_POLICY_BLOCKED`. Useful in audit-heavy environments where\n every write must declare provenance.\n- **`EPIMETHIAN_AUTO_UPGRADE`** \u2014 opt-in (default OFF). When `true`, the\n server checks for and applies updates on startup. Useful for managed\n fleets; usually you want explicit `epimethian-mcp upgrade` runs instead.\n- **`CONFLUENCE_READ_ONLY`** \u2014 opt-in (default OFF). When `true`, all\n write tools are disabled regardless of MCP client config. Useful for\n read-only profiles or sandbox environments.\n\n## Available Tools (35)\n\n| Tool | Description |\n|------|-------------|\n| `check_permissions` | Report the current profile\'s MCP access mode and the token\'s capabilities |\n| `create_page` | Create a new Confluence page |\n| `get_page` | Read a page by ID (use `headings_only` to preview structure first) |\n| `get_page_by_title` | Look up a page by title (use `headings_only` to preview structure first) |\n| `update_page` | Update an existing page |\n| `update_page_section` | Update a single section by heading name (supports `body` replacement OR `find_replace` literal substitutions) |\n| `update_page_sections` | Atomically update multiple sections in one version bump (all-or-nothing) |\n| `prepend_to_page` | Insert content at the beginning of an existing page (additive, safe) |\n| `append_to_page` | Insert content at the end of an existing page (additive, safe) |\n| `delete_page` | Delete a page |\n| `revert_page` | Revert a page to a previous version |\n| `list_pages` | List pages in a space |\n| `get_page_children` | Get child pages of a page |\n| `search_pages` | Search pages using CQL (Confluence Query Language) |\n| `get_spaces` | List available Confluence spaces |\n| `add_attachment` | Upload a file attachment to a page |\n| `get_attachments` | List attachments on a page |\n| `add_drawio_diagram` | Add a draw.io diagram to a page |\n| `get_labels` | Get all labels on a Confluence page |\n| `add_label` | Add one or more labels to a Confluence page |\n| `remove_label` | Remove a label from a Confluence page |\n| `get_page_status` | Get the content status badge on a page |\n| `set_page_status` | Set the content status badge on a page |\n| `remove_page_status` | Remove the content status badge from a page |\n| `get_comments` | Get footer and/or inline comments on a page |\n| `create_comment` | Create a footer or inline comment on a page |\n| `resolve_comment` | Resolve or reopen an inline comment |\n| `delete_comment` | Permanently delete a comment |\n| `get_page_versions` | List version history for a page |\n| `get_page_version` | Get page content at a specific historical version |\n| `diff_page_versions` | Compare two versions of a page |\n| `lookup_user` | Search for Atlassian users by name or email to resolve accountId for inline mentions |\n| `resolve_page_link` | Resolve a page title + space key to a stable contentId and URL for page links |\n| `get_version` | Return the epimethian-mcp server version and report available updates |\n| `upgrade` | Upgrade epimethian-mcp to the latest available version (restart required after) |\n';
50269
+ install_agent_default = '# Epimethian MCP - Agent Installation Guide\n\n> This document is for AI agents installing and configuring the Epimethian MCP server on behalf of a user.\n\n## Verification\n\nThe official package is **`@de-otio/epimethian-mcp`** (scoped under `@de-otio`).\n\nDo NOT install unscoped alternatives like `epimethian-mcp` \u2014 these are unofficial and potentially unsafe.\n\n## Prerequisites\n\n- Node.js 18 or later\n- npm on PATH\n\n## Step 1: Install\n\n```bash\nnpm install -g @de-otio/epimethian-mcp\n```\n\nVerify the installation:\n\n```bash\nepimethian-mcp --version 2>/dev/null || which epimethian-mcp\n```\n\n## Step 2: Resolve the absolute path\n\nIDE-hosted agents may not resolve PATH correctly. Always use the absolute path in MCP configuration:\n\n```bash\nwhich epimethian-mcp\n```\n\nUse the output as the `command` value in the MCP config below.\n\n## Step 3: Collect configuration\n\nAsk the user for:\n1. **Profile name** \u2014 a short identifier for this Confluence instance (e.g., `globex`, `acme-corp`). Lowercase alphanumeric and hyphens only.\n2. **Confluence Cloud URL** \u2014 e.g., `https://yoursite.atlassian.net`\n3. **Email address** \u2014 the email associated with their Atlassian account\n\n## Step 4: Write MCP configuration\n\nRun `epimethian-mcp setup --profile <name> --client <client-id>` after Step 5 (credential setup) \u2014 it prints the exact config snippet for your MCP host. Supported clients: `claude-code`, `claude-desktop`, `claude-code-vscode`, `cursor`, `windsurf`, `zed`, `opencode`. Keep the fallback hand-typed examples below for cases where the CLI is unavailable.\n\nAdd the server to the user\'s MCP client config. The exact file and shape depend on the client:\n\n**Claude Code, Claude Desktop, Cursor, Windsurf, Zed** \u2014 `.mcp.json` (or the\nequivalent client-specific config). The standard `mcpServers` shape:\n\n```json\n{\n "mcpServers": {\n "confluence": {\n "command": "<absolute path from Step 2>",\n "env": {\n "CONFLUENCE_PROFILE": "<profile name from Step 3>"\n }\n }\n }\n}\n```\n\n**OpenCode** \u2014 `opencode.json` at the project root or\n`~/.config/opencode/opencode.json`. Different shape (`mcp` block, `type:\n"local"`, `command` is an array, `environment` not `env`):\n\n```jsonc\n{\n "$schema": "https://opencode.ai/config.json",\n "mcp": {\n "confluence": {\n "type": "local",\n "command": ["<absolute path from Step 2>"],\n "enabled": true,\n "environment": {\n "CONFLUENCE_PROFILE": "<profile name from Step 3>",\n "EPIMETHIAN_ALLOW_UNGATED_WRITES": "true"\n }\n }\n }\n}\n```\n\nOpenCode does not support MCP elicitation (the in-protocol confirmation\nprompts), so write tools that fire the elicitation gate fail unless\n`EPIMETHIAN_ALLOW_UNGATED_WRITES=true` is set. See "MCP client\ncompatibility" below for the trade-off.\n\n**IMPORTANT:** The only required env var is `CONFLUENCE_PROFILE`. The URL,\nemail, and API token are stored securely in the OS keychain \u2014 they should\nNOT appear in config files.\n\n## Step 5: Credential setup\n\nTell the user to run this command in their terminal:\n\n```\nepimethian-mcp setup --profile <profile name from Step 3>\n```\n\nThis interactive command will:\n1. Prompt for the Confluence URL, email, and API token (masked input)\n2. Test the connection\n3. Store all credentials securely in the OS keychain under the named profile\n\nThe API token is generated at: https://id.atlassian.com/manage-profile/security/api-tokens\n\n**Do NOT ask the user for the API token yourself.** The token must go directly from the user into the interactive setup command to avoid appearing in conversation logs.\n\n## Step 6: User must restart the MCP client\n\n**IMPORTANT:** The user must restart their MCP client (e.g., restart Claude Code, reload VS Code, restart Claude Desktop) for the new server configuration to take effect. The MCP client reads `.mcp.json` at startup and does not detect changes while running.\n\nTell the user:\n> Please restart your MCP client now to activate the Confluence tools.\n\n## Step 7: Validation\n\nAfter the user restarts, verify the server is working by listing available Confluence tools or running a simple operation like listing spaces.\n\n## Adding Additional Tenants\n\nTo add a second Confluence instance (e.g., for a different customer):\n\n1. Run `epimethian-mcp setup --profile <new-profile-name>` with the new credentials\n2. In the project that uses the new tenant, update `.mcp.json` to set `CONFLUENCE_PROFILE` to the new profile name\n3. Restart the MCP client\n\nEach VS Code window / Claude Code session uses the profile specified in its `.mcp.json`. Profiles are fully isolated \u2014 different OS keychain entries, different Confluence instances.\n\n## Managing Profiles\n\n- List all profiles: `epimethian-mcp profiles`\n- Show details: `epimethian-mcp profiles --verbose`\n- Check connection: `CONFLUENCE_PROFILE=<name> epimethian-mcp status`\n- Set read-only: `epimethian-mcp profiles --set-read-only <name>`\n- Set read-write: `epimethian-mcp profiles --set-read-write <name>`\n\n### Read-Only Mode\n\nNew profiles default to **read-only**. When read-only, all write tools are blocked and return an error. To enable writes for a profile:\n\n```bash\nepimethian-mcp profiles --set-read-write <name>\n```\n\nOr during setup: `epimethian-mcp setup --profile <name> --read-write`\n\n**Important:** Restart any running MCP servers after changing the read-only flag.\n\n### Removing a Profile\n\nTo delete a profile and its credentials, run:\n\n```bash\nepimethian-mcp profiles --remove <name> --force\n```\n\n**Agents must pass `--force`** because the command normally prompts for interactive confirmation (`Remove profile "<name>" and delete its credentials? [y/N]`), which will fail in non-TTY environments like agent shell sessions. The `--force` flag skips the confirmation prompt when stdin is not a TTY.\n\nThis command:\n1. Deletes the credential entry (URL, email, API token) from the OS keychain\n2. Removes the profile from the registry at `~/.config/epimethian-mcp/profiles.json`\n3. Writes an entry to the audit log at `~/.config/epimethian-mcp/audit.log`\n\nAfter removing a profile, also remove or update any `.mcp.json` files that reference it \u2014 otherwise the MCP server will fail to start with a missing-profile error.\n\n**Errors:**\n- If the profile name is invalid (not matching lowercase alphanumeric/hyphens, 1\u201363 chars), the command exits with code 1\n- If the profile does not exist in the keychain, the keychain deletion is silently skipped \u2014 the registry entry is still removed\n\n## Accessing This Guide Post-Install\n\nOnce installed, this guide is available locally via:\n\n```bash\nepimethian-mcp agent-guide\n```\n\nThis prints the full agent guide to stdout \u2014 no web fetch required.\n\n## Uninstallation\n\nWhen a user asks to uninstall Epimethian MCP, follow these steps:\n\n### Step 1: Check for existing profiles\n\n```bash\nepimethian-mcp profiles\n```\n\n### Step 2: Ask the user about credential cleanup\n\nIf profiles exist, ask the user:\n\n> You have Epimethian profiles configured: [list the profile names]. Would you like to delete all stored credentials before uninstalling? (This removes API tokens from your OS keychain.)\n\n### Step 3: Delete credentials (if the user agrees)\n\nFor each profile the user wants removed:\n\n```bash\nepimethian-mcp profiles --remove <name> --force\n```\n\nOr to remove all profiles:\n\n```bash\nfor name in $(epimethian-mcp profiles | grep \'^ \'); do epimethian-mcp profiles --remove "$name" --force; done\n```\n\n### Step 4: Remove MCP configuration\n\nDelete the `confluence` entry (or the tenant-specific entry like `confluence-globex`) from the project\'s `.mcp.json`.\n\n### Step 5: Uninstall the package\n\n```bash\nnpm uninstall -g @de-otio/epimethian-mcp\n```\n\n### Step 6: Restart the MCP client\n\nTell the user to restart their MCP client so it stops trying to launch the removed server.\n\n## CI/CD (No Keychain)\n\nFor environments where the OS keychain is unavailable (Docker, CI), set all three env vars directly:\n\n```json\n{\n "mcpServers": {\n "confluence": {\n "command": "<absolute path>",\n "env": {\n "CONFLUENCE_URL": "<url>",\n "CONFLUENCE_EMAIL": "<email>",\n "CONFLUENCE_API_TOKEN": "<token>"\n }\n }\n }\n}\n```\n\n**Warning:** This exposes the API token in the process environment. Use profile-based auth whenever possible.\n\n## Troubleshooting\n\nIf **npm install fails**:\n- Verify Node.js 18+ is installed: `node --version`\n- Verify npm is on PATH: `npm --version`\n- If permission errors occur, the user may need to fix their npm prefix or use a Node version manager (nvm, fnm)\n\nIf **`epimethian-mcp setup` fails**:\n- "Connection failed": Verify the Confluence URL is correct and accessible\n- "Token is invalid or expired": The user needs to generate a new API token at https://id.atlassian.com/manage-profile/security/api-tokens\n- Keychain errors on Linux: The user may need to install `libsecret` / `gnome-keyring` (`apt install libsecret-tools` or equivalent)\n\nIf **the server doesn\'t appear after restart**:\n- Verify the `.mcp.json` path is correct for the user\'s MCP client\n- Verify the `command` value is an absolute path (run `which epimethian-mcp` to confirm)\n- Check that `.mcp.json` contains valid JSON (no trailing commas, correct quoting)\n\n## Write budget (safety cap on writes)\n\nepimethian-mcp enforces two write-rate caps per server process:\n\n- **Session cap** (default 250): total writes since the server started.\n- **Rolling cap** (default 75 per 15-minute window): catches bursts.\n\nThese are local safety nets, not Confluence limits. They exist because an\nautonomous agent in a retry loop or with a bad plan can issue hundreds of writes\nvery quickly, and most users would rather have a brief pause to confirm than\ndiscover the result an hour later.\n\n### What to do when you (the agent) hit `WRITE_BUDGET_EXCEEDED`\n\n1. **Stop and check.** Was the in-progress work user-requested and going as\n planned? If unsure, ask the user before continuing.\n2. **Explain to the user, in your own words:**\n - The safety budget has been hit (which scope, current vs. limit).\n - What the budget is for: a guard against runaway agents.\n - Whether the work-in-progress is legitimate (your judgement).\n - The two ways forward: wait for the rolling window to reopen, or raise the cap.\n3. **If the user wants to raise the cap**, give them this snippet to add to the\n `env` block of the epimethian-mcp entry in their MCP config (`.mcp.json` or\n equivalent \u2014 see Step 4 above for the layout):\n\n ```json\n "EPIMETHIAN_WRITE_BUDGET_ROLLING": "200",\n "EPIMETHIAN_WRITE_BUDGET_SESSION": "1000"\n ```\n\n Set either value to `"0"` to disable that scope. **Confirm with the user\n before recommending a raise** \u2014 the budget exists precisely to create a\n pause-and-check moment. The user must restart the MCP server (re-open the\n MCP client) for changes to take effect.\n4. **If the user gets a deprecation warning** about `EPIMETHIAN_WRITE_BUDGET_HOURLY`,\n tell them to rename it to `EPIMETHIAN_WRITE_BUDGET_ROLLING` in the same\n config file. The old name still works but will be removed in version 7.\n\n### Operator-side defaults\n\n- **`EPIMETHIAN_WRITE_BUDGET_SESSION`** \u2014 default 250; set to "0" to disable.\n- **`EPIMETHIAN_WRITE_BUDGET_ROLLING`** \u2014 default 75 per 15-minute window; set to "0" to disable.\n- **`EPIMETHIAN_WRITE_BUDGET_HOURLY`** \u2014 deprecated alias for `EPIMETHIAN_WRITE_BUDGET_ROLLING`; will be removed in version 7.\n\n## Soft confirmation (clients without working elicitation)\n\nSome MCP clients (OpenCode, the Claude Code VS Code extension, and\nothers) don\'t implement the in-protocol confirmation prompt \u2014 either\nthey don\'t advertise the capability, or they advertise it and never\nhonour the request (the SDK transport silently returns\n`{action: "decline"}` without showing the user a UI). Starting in\nv6.6.0, epimethian-mcp routes those confirmations through your\nagent\'s normal chat surface instead.\n\nThe implementation evolved across v6.6.0 \u2192 v6.6.3:\n\n- **v6.6.0** introduced soft elicitation for clients that don\'t\n *advertise* the capability \u2014 token-bound, single-use, diff-bound.\n- **v6.6.1** added **fast-decline auto-detection**: if the client\n advertises elicitation but the decline arrives in <50 ms (well below\n human reaction time), the session is flagged as fake and the call\n is re-routed through the soft-confirm path automatically. Threshold\n is overridable via `EPIMETHIAN_FAST_DECLINE_THRESHOLD_MS=<10..5000>`.\n No env-var configuration needed for the Claude Code VS Code\n extension\'s "fakes elicitation" bug \u2014 it Just Works.\n- **v6.6.2** declared `outputSchema` on every mutating tool so\n spec-compliant clients are obliged to forward `structuredContent`\n (where the token lives) to the agent. Added an opt-in\n `EPIMETHIAN_TOKEN_IN_TEXT=true` fallback that appends the full\n token to `content[0].text` for clients that drop `content` blocks\n on `isError: true` results (Claude Code issues #15412 / #9962 /\n #39976).\n- **v6.6.3** swapped the `outputSchema` from `z.discriminatedUnion`\n to `z.object` so the MCP SDK\'s `normalizeObjectSchema` (which only\n accepts schemas with `.shape`) can route the structured payload\n through `validateToolOutput` without throwing `_zod` undefined\n after the write commits. Hotfix; data-integrity-critical.\n- **v6.7.2** flipped the token-in-text fallback to default-on: the\n full token now appears in `content[0].text` without needing an env\n var, since most clients we care about (Claude Code in particular)\n drop `structuredContent` on `isError: true` results. Set\n `EPIMETHIAN_HIDE_TOKEN_IN_TEXT=true` to opt out for clients that\n reliably forward `structuredContent` (or to keep the token out of\n the agent transcript on principle). The legacy\n `EPIMETHIAN_TOKEN_IN_TEXT=false` spelling is also honoured for\n back-compat.\n\n### What you (the agent) see\n\nWhen a destructive write is requested against a client without working\nelicitation, the tool returns an error with a confirmation token:\n\n```\nisError: true\nstructuredContent:\n {\n "kind": "confirmation_required",\n "confirm_token": "<opaque token>",\n "audit_id": "<UUID for correlation>",\n "expires_at": "<ISO timestamp>",\n "page_id": "<pageId>",\n "human_summary": "<one-line description for the user>",\n "deletion_summary": { ... numeric counts only ... } // optional\n }\ncontent[0].text:\n \u26A0\uFE0F Confirmation required (SOFT_CONFIRMATION_REQUIRED)\n\n {human_summary}\n\n Please ask the user before retrying. If approved, re-call with the\n same parameters plus "confirm_token" from structuredContent.\n\n Token tail: ...<last 8 chars> Expires: <timestamp> Audit ID: <uuid>\n\n [FALLBACK] Full token: <full token>\n \u2190 present by default in v6.7.2+; suppressed when\n EPIMETHIAN_HIDE_TOKEN_IN_TEXT=true (or the legacy\n EPIMETHIAN_TOKEN_IN_TEXT=false) is set\n```\n\nThe `kind` discriminator distinguishes this `"confirmation_required"`\narm from the success arm (`"written"` or `"deleted"`) on the same\ntool. Successful writes return:\n\n```\nstructuredContent:\n { "kind": "written", "page_id": "...", "new_version": 12, ... }\n```\n\n\u2026or `"kind": "deleted"` for `delete_page`.\n\n### What to do\n\n1. STOP. Don\'t retry blindly.\n2. Show the user, in their language, what\'s about to happen \u2014 use the\n `human_summary` field from `structuredContent`, or the\n human-readable text in `content[0].text` if your client doesn\'t\n forward `structuredContent`. **Never echo the token bytes to the\n user** \u2014 the token is meant to flow agent \u2192 server, not user \u2192 eye.\n3. Ask the user explicitly. Wait for their answer.\n4. If approved: re-call the tool with the SAME parameters plus\n `confirm_token`. Read the token from `structuredContent.confirm_token`\n when available; otherwise the token\'s full bytes are in the\n `[FALLBACK] Full token: \u2026` line of `content[0].text` (present by\n default in v6.7.2+, suppressed by `EPIMETHIAN_HIDE_TOKEN_IN_TEXT=true`).\n The 8-character "Token tail: \u2026" line in the prose is for human\n inspection only \u2014 it is **not** the token.\n5. If denied: tell the user the operation has been cancelled.\n\n### Token semantics\n\n- Single-use: a successful retry consumes the token. Replays fail.\n- 5-minute TTL by default.\n- Invalidated by any competing write to the same page (stale).\n- Bound to the specific diff and tenant: changing the body, page version, or\n tenant invalidates the token.\n\n### Operator opt-outs\n\nThese environment variables control soft confirmation behavior:\n\n- **`EPIMETHIAN_ALLOW_UNGATED_WRITES=true`** \u2014 bypasses soft confirmation\n entirely (no prompt; useful for headless / CI). Removes the\n human-in-the-loop gate; the harness\'s tool allow-list still applies.\n- **`EPIMETHIAN_DISABLE_SOFT_CONFIRM=true`** \u2014 keeps the legacy\n `ELICITATION_REQUIRED_BUT_UNAVAILABLE` failure mode for clients without\n elicitation support.\n- **`EPIMETHIAN_SOFT_CONFIRM_TTL_MS=300000`** \u2014 override the default 5-minute\n TTL (clamped to 60 seconds minimum, 15 minutes maximum).\n- **`EPIMETHIAN_SOFT_CONFIRM_MINT_LIMIT=100`** \u2014 override the per-15-minute\n mint cap (default 100; "0" disables the cap entirely).\n\n#### v6.6.1+ fast-decline auto-detection (Claude Code VS Code et al.)\n\n- **`EPIMETHIAN_TREAT_ELICITATION_AS_UNSUPPORTED=true`** \u2014 deterministic\n counterpart to fast-decline auto-detection. Use when your client is\n known to advertise elicitation but never honour it (e.g. the Claude\n Code VS Code extension \u2264 2.1.123) and you want to skip the timing\n probe on the first call. Distinct from `EPIMETHIAN_BYPASS_ELICITATION`:\n this routes through the soft-confirmation gate; bypass removes the\n gate entirely.\n- **`EPIMETHIAN_FAST_DECLINE_THRESHOLD_MS=<10..5000>`** \u2014 override the\n fast-decline threshold (default 50 ms). Raise this if a slow MCP\n transport is mis-classifying real declines as fake.\n- **`EPIMETHIAN_DISABLE_FAST_DECLINE_DETECTION=true`** \u2014 total\n off-switch for the auto-detection; restores exactly v6.6.0 behaviour.\n\n#### v6.7.2+ token-in-text default\n\nThe soft-confirm result text appends a `[FALLBACK] Full token: <token>`\nline by default, so the token is visible to the agent even when the\nclient drops `structuredContent` on `isError: true` (Claude Code issues\n#15412 / #9962 / #39976). This matches the configuration most clients\nneed; making it the default closes the under-configured-install gap.\nThe structured payload is unchanged.\n\n- **`EPIMETHIAN_HIDE_TOKEN_IN_TEXT=true`** \u2014 opt out of the in-text\n fallback. Use when your client reliably forwards `structuredContent`\n to the agent and you\'d rather keep the token out of the transcript\n (the security choice v6.6.0 originally optimised for).\n- **`EPIMETHIAN_TOKEN_IN_TEXT=false`** \u2014 legacy spelling for the same\n opt-out, honoured for back-compat with v6.6.x configs that pinned\n the env var to a disabled value. Any other value (unset, "true",\n "1", typos) leaves the default-on behaviour in place.\n\n#### v6.8.0+ batch authorisation tokens\n\nWhen you need to fan out destructive writes across many pages \u2014 bulk\ndoc refreshes, post-migration cleanups, sub-agent fan-out \u2014 use\n`authorise_destructive_writes` once to get a `batch_token`, then pass\nit to every subsequent `update_page` / `update_page_section` /\n`update_page_sections` / `delete_page` call. ONE user prompt covers\nthe entire batch.\n\n```\n[parent agent]\n \u2192 AskUserQuestion("Rewrite these 13 pages?") \u2190 agent harness UX\n \u2192 user approves\n \u2192 mcp.authorise_destructive_writes({\n page_ids: [...13 IDs...],\n ttl_seconds: 3600,\n max_operations: 13,\n reason: "Refresh all runbook pages with v6.8.0 release notes"\n })\n \u2192 server fires soft-confirm OR live elicitation (ONE prompt)\n \u2192 batch_token returned to parent\n \u2193\n[parent dispatches 13 sub-agents, each given the batch_token]\n \u2193\n[sub-agent N]\n \u2192 reads sources, drafts page N\n \u2192 mcp.update_page({\n page_id, body, replace_body: true,\n source: "user_request",\n batch_token // \u2190 bypasses the per-call gate\n })\n \u2192 server validates batch_token; on match, decrements the operation\n counter and writes; on any failure (wrong page, expired,\n exhausted, cloud mismatch), falls through to the per-call\n confirmation gate transparently\n```\n\n**What the agent should know:**\n\n- The `batch_token` is page-id-scoped. A call to a page outside the\n approved list looks like a normal first-call destructive write \u2014\n the agent gets `SOFT_CONFIRMATION_REQUIRED`, NOT a different error\n class (this is the validation-failure invariant: granular reasons\n never leak). The fall-through is the right ergonomics; the agent\n can recover by handling the per-call confirm_token round-trip.\n- The token is NOT diff-bound. The user approved which pages may be\n written to and how many times \u2014 not the exact bytes of each write.\n This is a real reduction in defence vs. the per-call `confirm_token`\n flow: a poisoned page can make the agent draft adversarial content,\n but only for pages already in the allowlist (the injection cannot\n redirect the write target). Don\'t request a batch token for pages\n the agent discovered autonomously.\n- The token has a maximum TTL of 1 hour and a maximum operation cap\n of `page_ids.length \xD7 2` (network-retry headroom \u2014 not an\n application-level retry budget). Defaults are 15 min and `N \xD7 1`.\n- The token covers `update_page`, `update_page_section`,\n `update_page_sections`, and `delete_page`. It does NOT cover\n `create_page` (no target page_id), `prepend_to_page` /\n `append_to_page` (use the per-call gate; these are non-destructive\n by default), or `find_replace` mode of `update_page_section` (also\n non-destructive).\n- Tokens are process-local. Multi-process deployments hit the same\n caveat as `confirm_token` \u2014 pin one process per agent.\n\n**Operator opt-outs / strict mode:**\n\n- **`EPIMETHIAN_BATCH_REQUIRES_ELICITATION=true`** \u2014 refuse to mint a\n batch token via the soft-confirm fallback. Forces the user to\n approve via real (in-protocol) elicitation. Use when you want to\n preserve the strong per-call diff-binding and only allow batch\n authorisation through clients that can render a real confirmation\n UI.\n- **`EPIMETHIAN_BATCH_MINT_LIMIT=<n>`** \u2014 override the rolling\n 15-minute mint cap (default 25; "0" disables). Distinct from the\n `confirm_token` mint budget.\n\n### Multi-process deployments\n\nTokens are process-local in-memory. If you\'re running multiple MCP server\nprocesses for one tenant (e.g. a load-balanced fleet or separate processes\nper IDE window), a soft confirmation minted by process P1 will fail validation\nin process P2 (the load balancer routes the retry to a different process).\nThis is not a bug \u2014 it\'s the safe failure mode \u2014 but it means the user needs\nto mint a new token if the retry lands on a different process.\n\n**Recommendation:** Pin a single MCP server process per agent or IDE window.\nPre-seal profiles upgraded from versions before v5.5.0 must run `epimethian-mcp\nsetup` once to acquire a sealed cloudId before soft confirmation is available.\n\n## MCP client compatibility\n\nepimethian-mcp uses MCP **elicitation** (the in-protocol confirmation\nprompt added to MCP in 2025) as the human-in-the-loop gate for destructive\noperations. Different MCP clients support elicitation differently \u2014 some\nfully, some not at all, and some advertise the capability without honouring\nit. The compatibility matrix below tells you which env-var workaround to\nrecommend, if any.\n\n| Client | Elicitation? | What to do (v6.6.3+) |\n|---|---|---|\n| **Claude Code (CLI)** | Yes \u2014 full support | No special config needed. |\n| **Claude Desktop** | Yes \u2014 full support | No special config needed. |\n| **Claude Code VS Code extension** (all versions tested through 2.1.x) | Fakes it (advertises capability, never honours) | v6.6.1\'s fast-decline auto-detection routes the call through soft-confirm automatically. v6.7.2+ surfaces the full token in `content[0].text` by default (Claude Code does not forward `structuredContent` on `isError: true` \u2014 issue #15412), so no env-var configuration is needed. No `EPIMETHIAN_BYPASS_ELICITATION` either; the gate works through the soft-confirm token flow. |\n| **OpenCode** | No \u2014 capability not advertised | v6.6.0+ soft-confirmation token flow is automatic when the client lacks elicitation. The Vercel AI SDK forwards `structuredContent` when `outputSchema` is declared (v6.6.2+); v6.7.2+ also surfaces the token in `content[0].text` by default as a defence-in-depth fallback. Set `EPIMETHIAN_HIDE_TOKEN_IN_TEXT=true` if you\'d rather keep the token out of the transcript on a build that does forward `structuredContent` reliably. `EPIMETHIAN_ALLOW_UNGATED_WRITES=true` remains an escape hatch for headless / CI runs where no human is in the loop. |\n| **Cursor / Windsurf / Zed / others** | Varies | If write tools fail with `ELICITATION_REQUIRED_BUT_UNAVAILABLE`, the client doesn\'t advertise the capability \u2014 soft-confirm should kick in automatically; use `EPIMETHIAN_ALLOW_UNGATED_WRITES=true` only if no human is in the loop. v6.7.2+ ships the full token in `content[0].text` by default, so the agent should always be able to read it. If the client *advertises* elicitation but always fails fast (decline arrives in <50 ms), v6.6.1\'s auto-detection re-routes through soft-confirm. Set `EPIMETHIAN_TREAT_ELICITATION_AS_UNSUPPORTED=true` to skip the timing probe on the first call when the client is known to fake it. |\n\n### Three configuration paths \u2014 pick the one that matches your client\n\nThese flags are **not interchangeable**. The newer (v6.6.x) flags\npreserve the human-in-the-loop gate; the older bypass flags remove it.\nDefault to preserving the gate.\n\n- **(Recommended) Soft-confirm token flow.** No env-var bypass needed.\n v6.6.0 + v6.6.1 + v6.6.3 give you a working soft-confirm round-trip\n for any client whose host can put a "do you approve?" prompt in front\n of the user (chat surface, tool-result UI, etc.). For clients with\n rendering quirks, layer on `EPIMETHIAN_TOKEN_IN_TEXT=true`\n (additive \u2014 keeps the gate, just exposes the token in\n `content[0].text` too).\n- **`EPIMETHIAN_ALLOW_UNGATED_WRITES=true`** \u2014 for clients that *don\'t\n advertise* elicitation AND have no other way to surface a\n confirmation prompt (e.g. headless / CI runs). Removes the gate\n entirely; only the harness allow-list and server-side guards remain.\n Pre-v6.6.0 escape hatch; rarely needed today.\n- **`EPIMETHIAN_BYPASS_ELICITATION=true`** \u2014 unconditional bypass,\n regardless of whether the client advertises elicitation. Original\n v6.4.1 escape hatch for Claude Code VS Code\'s "fakes elicitation"\n bug. **In v6.6.1+ this is no longer needed** for that bug \u2014 the\n fast-decline detector routes around it. Use only if you specifically\n want to remove the gate.\n\n### Trade-off: what you give up by setting `ALLOW_UNGATED_WRITES` or `BYPASS_ELICITATION`\n\nThese two flags **disable the human-in-the-loop confirmation gate\nentirely**. Writes still go through the harness\'s tool allow-list (so\nusers can still block the tool in their permission settings) and\nthrough every server-side guard (provenance, source-policy,\nwrite-budget, byte-equivalence) \u2014 but the user no longer gets a\nprompt before each destructive operation. Recommend this only when:\n\n1. The user is aware of and accepts the trade-off, AND\n2. The user\'s MCP client provides some other interaction model where they\n can intervene (e.g. they review tool calls before approval), OR\n3. The work is read-mostly and only occasional, additive writes happen.\n\n**Do NOT set either flag silently.** If you (the agent) need to recommend\none, explain to the user what the gate is for, why their client can\'t\nhonour it, and what alternative protections remain.\n\n`EPIMETHIAN_TREAT_ELICITATION_AS_UNSUPPORTED` and\n`EPIMETHIAN_TOKEN_IN_TEXT` (v6.6.1+ / v6.6.2+) are different \u2014 they\n**preserve** the gate by routing through the soft-confirm token flow.\nThey affect how the prompt reaches the user, not whether one happens.\nPrefer these to the bypass flags whenever the client supports any\nform of agent \u2194 user dialogue.\n\n## Other operator-side environment variables\n\nThese are off by default and only relevant in specific scenarios:\n\n- **`EPIMETHIAN_SUPPRESS_EQUIVALENT_DELETIONS`** \u2014 opt-in (default OFF).\n When set to `true`, suppresses the `confirm_deletions` gate for token\n deletion+creation pairs that canonicalise to byte-equivalent XML\n (e.g. re-rendering the same `<ac:link>` macros with different attribute\n order, or regenerating an `<ac:structured-macro>` whose parameters and\n CDATA body are identical after sort). Genuine semantic deletions still\n fire the gate. Every suppressed pair is recorded in the mutation log\n for postmortem. Useful for spaces with lots of cross-link rewrites\n where the gate fires repeatedly on no-op churn.\n- **`EPIMETHIAN_REQUIRE_SOURCE`** \u2014 opt-in (default OFF). When `true`,\n every write tool call must include a `source` parameter (one of\n `user_request` / `file_or_cli_input` / `chained_tool_output` /\n `elicitation_response`). Calls without an explicit source are rejected\n with `SOURCE_POLICY_BLOCKED`. Useful in audit-heavy environments where\n every write must declare provenance.\n- **`EPIMETHIAN_AUTO_UPGRADE`** \u2014 opt-in (default OFF). When `true`, the\n server checks for and applies updates on startup. Useful for managed\n fleets; usually you want explicit `epimethian-mcp upgrade` runs instead.\n- **`CONFLUENCE_READ_ONLY`** \u2014 opt-in (default OFF). When `true`, all\n write tools are disabled regardless of MCP client config. Useful for\n read-only profiles or sandbox environments.\n\n## Available Tools (36)\n\n| Tool | Description |\n|------|-------------|\n| `check_permissions` | Report the current profile\'s MCP access mode and the token\'s capabilities |\n| `create_page` | Create a new Confluence page |\n| `get_page` | Read a page by ID (use `headings_only` to preview structure first) |\n| `get_page_by_title` | Look up a page by title (use `headings_only` to preview structure first) |\n| `update_page` | Update an existing page |\n| `update_page_section` | Update a single section by heading name (supports `body` replacement OR `find_replace` literal substitutions) |\n| `update_page_sections` | Atomically update multiple sections in one version bump (all-or-nothing) |\n| `prepend_to_page` | Insert content at the beginning of an existing page (additive, safe) |\n| `append_to_page` | Insert content at the end of an existing page (additive, safe) |\n| `delete_page` | Delete a page |\n| `authorise_destructive_writes` | Pre-authorise a batch of destructive writes (returns `batch_token` consumed by subsequent update / delete calls) |\n| `revert_page` | Revert a page to a previous version |\n| `list_pages` | List pages in a space |\n| `get_page_children` | Get child pages of a page |\n| `search_pages` | Search pages using CQL (Confluence Query Language) |\n| `get_spaces` | List available Confluence spaces |\n| `add_attachment` | Upload a file attachment to a page |\n| `get_attachments` | List attachments on a page |\n| `add_drawio_diagram` | Add a draw.io diagram to a page |\n| `get_labels` | Get all labels on a Confluence page |\n| `add_label` | Add one or more labels to a Confluence page |\n| `remove_label` | Remove a label from a Confluence page |\n| `get_page_status` | Get the content status badge on a page |\n| `set_page_status` | Set the content status badge on a page |\n| `remove_page_status` | Remove the content status badge from a page |\n| `get_comments` | Get footer and/or inline comments on a page |\n| `create_comment` | Create a footer or inline comment on a page |\n| `resolve_comment` | Resolve or reopen an inline comment |\n| `delete_comment` | Permanently delete a comment |\n| `get_page_versions` | List version history for a page |\n| `get_page_version` | Get page content at a specific historical version |\n| `diff_page_versions` | Compare two versions of a page |\n| `lookup_user` | Search for Atlassian users by name or email to resolve accountId for inline mentions |\n| `resolve_page_link` | Resolve a page title + space key to a stable contentId and URL for page links |\n| `get_version` | Return the epimethian-mcp server version and report available updates |\n| `upgrade` | Upgrade epimethian-mcp to the latest available version (restart required after) |\n';
49952
50270
  }
49953
50271
  });
49954
50272
 
@@ -49973,7 +50291,7 @@ __export(upgrade_exports, {
49973
50291
  runUpgrade: () => runUpgrade
49974
50292
  });
49975
50293
  async function runUpgrade() {
49976
- const currentVersion = "6.7.1";
50294
+ const currentVersion = "6.9.0";
49977
50295
  console.log(`epimethian-mcp upgrade: current version v${currentVersion}`);
49978
50296
  let pending = await getPendingUpdate();
49979
50297
  if (!pending) {
@@ -60223,6 +60541,7 @@ init_zod();
60223
60541
  var import_promises4 = require("node:fs/promises");
60224
60542
  var import_node_os3 = require("node:os");
60225
60543
  var import_node_path4 = require("node:path");
60544
+ var import_node_crypto7 = require("node:crypto");
60226
60545
  init_confluence_client();
60227
60546
 
60228
60547
  // node_modules/diff/libesm/diff/base.js
@@ -60866,6 +61185,7 @@ async function markPageUnverified(pageId, cfg) {
60866
61185
 
60867
61186
  // src/server/index.ts
60868
61187
  init_safe_write();
61188
+ init_batch_tokens();
60869
61189
 
60870
61190
  // src/server/source-provenance.ts
60871
61191
  init_zod();
@@ -61206,6 +61526,29 @@ var deleteOutputSchema = external_exports.object({
61206
61526
  human_summary: external_exports.string().optional(),
61207
61527
  deletion_summary: deletionSummarySchema.optional()
61208
61528
  });
61529
+ var batchAuthorisedArm = external_exports.object({
61530
+ kind: external_exports.literal("batch_authorised"),
61531
+ batch_token: external_exports.string().min(1),
61532
+ audit_id: external_exports.string().min(1),
61533
+ expires_at: external_exports.string().min(1),
61534
+ authorised_page_ids: external_exports.array(external_exports.string().min(1)).min(1),
61535
+ remaining_operations: external_exports.number().int().nonnegative()
61536
+ });
61537
+ var batchAuthOutputSchema = external_exports.object({
61538
+ kind: external_exports.enum(["batch_authorised", "confirmation_required"]),
61539
+ // shared (audit_id and expires_at appear on both arms)
61540
+ audit_id: external_exports.string().min(1).optional(),
61541
+ expires_at: external_exports.string().min(1).optional(),
61542
+ // batch_authorised arm fields
61543
+ batch_token: external_exports.string().min(1).optional(),
61544
+ authorised_page_ids: external_exports.array(external_exports.string().min(1)).optional(),
61545
+ remaining_operations: external_exports.number().int().nonnegative().optional(),
61546
+ // confirmation_required arm fields
61547
+ confirm_token: external_exports.string().min(1).optional(),
61548
+ page_id: external_exports.string().min(1).optional(),
61549
+ human_summary: external_exports.string().optional(),
61550
+ deletion_summary: deletionSummarySchema.optional()
61551
+ });
61209
61552
 
61210
61553
  // src/server/index.ts
61211
61554
  init_update_orchestrator();
@@ -61943,6 +62286,148 @@ ${truncated}${truncationNote(origLen)}`
61943
62286
  }
61944
62287
  }
61945
62288
  );
62289
+ server.registerTool(
62290
+ "authorise_destructive_writes",
62291
+ {
62292
+ description: describeWithLock(
62293
+ withDestructiveWarning(
62294
+ 'Pre-authorise a batch of destructive Confluence write operations. Returns a `batch_token` that can be passed to subsequent `update_page` / `update_page_section` / `update_page_sections` / `delete_page` calls in place of `confirm_token`. A SINGLE user prompt covers the entire batch.\n\nUse this when fanning out destructive writes across multiple pages \u2014 sub-agent fan-outs, bulk doc refreshes, post-migration cleanups. The token is page-id-scoped: calls to pages outside the `page_ids` allowlist fall through to the per-call confirmation gate (no different error class than a normal destructive write would produce).\n\nTrade-off vs. per-call `confirm_token`: a batch_token is NOT diff-bound. The user approves which pages may be written to and how many times, but not the exact bytes of each write. This weakens the defence against page-content-driven prompt injection that targets the same page being viewed. Use only when the user has explicitly authorised the batch (e.g. "rewrite these 13 runbook pages") \u2014 never for content the agent discovered autonomously.\n\nIf your MCP client does not support in-protocol confirmation, this tool returns `SOFT_CONFIRMATION_REQUIRED` on the first call. STOP and ask the user. If approved, re-call with the same parameters plus `confirm_token`. The `batch_token` returned then covers all subsequent destructive writes within `page_ids` until `ttl_seconds` elapse or `max_operations` are exhausted.'
62295
+ ),
62296
+ config3
62297
+ ),
62298
+ inputSchema: {
62299
+ page_ids: external_exports.array(external_exports.string().min(1)).min(1).max(50).describe(
62300
+ "Explicit list of page IDs the batch_token will be valid for. 1..50 entries. Duplicates are silently deduplicated. Wildcards are NOT supported \u2014 by design, to prevent injection-redirected writes."
62301
+ ),
62302
+ ttl_seconds: external_exports.number().int().min(60).max(3600).default(900).describe(
62303
+ "Token lifetime in seconds. Clamped to [60, 3600]. Default 900 (15 min)."
62304
+ ),
62305
+ max_operations: external_exports.number().int().min(1).max(100).optional().describe(
62306
+ "Total operations the token authorises across all pages. Defaults to `page_ids.length` (one successful write per page). Maximum is `page_ids.length \xD7 2` \u2014 headroom for network-level retries the server cannot distinguish from real conflicts; do NOT rely on it for application-level retry budgets."
62307
+ ),
62308
+ reason: external_exports.string().min(10).max(500).describe(
62309
+ 'Human-readable purpose, shown to the user during the confirmation prompt (e.g. "Bulk doc refresh: rewrite N runbook pages with v6.8.0 release notes"). Required so the user can make an informed authorisation decision.'
62310
+ ),
62311
+ source: sourceSchema,
62312
+ confirm_token: external_exports.string().optional().describe(
62313
+ "Soft-confirmation token from a prior SOFT_CONFIRMATION_REQUIRED response on this same tool. Single-use; bound to the exact {page_ids, ttl_seconds, max_operations, reason} tuple."
62314
+ )
62315
+ },
62316
+ // v6.8.0 — declared so spec-compliant clients forward
62317
+ // structuredContent (which carries the batch_token) to the agent.
62318
+ // Same rationale as v6.6.2 §3.1 for the other write tools.
62319
+ outputSchema: batchAuthOutputSchema,
62320
+ annotations: { destructiveHint: true, idempotentHint: false }
62321
+ },
62322
+ async ({ page_ids, ttl_seconds, max_operations, reason, source, confirm_token }) => {
62323
+ const blocked = writeGuard("authorise_destructive_writes", config3);
62324
+ if (blocked) return blocked;
62325
+ try {
62326
+ const effectiveSource = validateSource(source, ["authorise_destructive_writes"]);
62327
+ const requireLiveElicitation = process.env.EPIMETHIAN_BATCH_REQUIRES_ELICITATION === "true";
62328
+ const cfg = await getConfig();
62329
+ const cloudId = cfg.sealedCloudId;
62330
+ if (cloudId === void 0) {
62331
+ throw new Error(
62332
+ "authorise_destructive_writes requires a sealed cloudId. Run `epimethian-mcp setup` once to acquire one."
62333
+ );
62334
+ }
62335
+ for (const pageId of page_ids) {
62336
+ await checkSpaceAllowed({ pageId });
62337
+ }
62338
+ const dedupedPageIds = Array.from(new Set(page_ids));
62339
+ const resolvedTtl = Math.min(3600, Math.max(60, Math.floor(ttl_seconds)));
62340
+ const N = dedupedPageIds.length;
62341
+ const resolvedMax = Math.min(N * 2, max_operations ?? N);
62342
+ const requestDigestSrc = JSON.stringify({
62343
+ tool: "authorise_destructive_writes",
62344
+ cloudId,
62345
+ page_ids: [...dedupedPageIds].sort(),
62346
+ ttl_seconds: resolvedTtl,
62347
+ max_operations: resolvedMax,
62348
+ reason
62349
+ });
62350
+ const diffHash = (0, import_node_crypto7.createHash)("sha256").update(requestDigestSrc).digest("hex");
62351
+ const SYNTH_PAGE_ID = "__batch_authorisation__";
62352
+ const SYNTH_PAGE_VERSION = 1;
62353
+ const tokenResult = await maybeConsumeConfirmToken({
62354
+ confirm_token,
62355
+ tool: "authorise_destructive_writes",
62356
+ cloudId,
62357
+ pageId: SYNTH_PAGE_ID,
62358
+ pageVersion: SYNTH_PAGE_VERSION,
62359
+ diffHash
62360
+ });
62361
+ if (tokenResult === "invalid") {
62362
+ throw new ConverterError(
62363
+ "The confirmation token is no longer valid. Mint a new one by re-calling this tool without confirm_token, ask the user again, then retry with the new token.",
62364
+ "CONFIRMATION_TOKEN_INVALID"
62365
+ );
62366
+ } else if (tokenResult === "no_token") {
62367
+ const idsList = dedupedPageIds.join(", ");
62368
+ const summary = `Pre-authorise destructive writes to ${N} page${N === 1 ? "" : "s"}: ${idsList}. Up to ${resolvedMax} operation${resolvedMax === 1 ? "" : "s"} within ${resolvedTtl} seconds. Reason: ${reason}. Source: ${effectiveSource}.`;
62369
+ if (requireLiveElicitation && !effectiveSupportsElicitation(server)) {
62370
+ throw new Error(
62371
+ "EPIMETHIAN_BATCH_REQUIRES_ELICITATION=true: refusing to mint a batch token via the soft-confirm fallback. The connected client must support in-protocol elicitation. Either enable elicitation on the client, fall back to per-page confirm_token, or unset EPIMETHIAN_BATCH_REQUIRES_ELICITATION."
62372
+ );
62373
+ }
62374
+ await gateOperation(server, {
62375
+ tool: "authorise_destructive_writes",
62376
+ summary,
62377
+ details: {
62378
+ page_count: N,
62379
+ max_operations: resolvedMax,
62380
+ ttl_seconds: resolvedTtl,
62381
+ source: effectiveSource
62382
+ },
62383
+ cloudId,
62384
+ pageId: SYNTH_PAGE_ID,
62385
+ pageVersion: SYNTH_PAGE_VERSION,
62386
+ diffHash
62387
+ });
62388
+ }
62389
+ const batch = mintBatchToken({
62390
+ cloudId,
62391
+ pageIds: dedupedPageIds,
62392
+ ttlSeconds: resolvedTtl,
62393
+ maxOperations: resolvedMax
62394
+ });
62395
+ const expiresIso = new Date(batch.expiresAt).toISOString();
62396
+ const idsLine = dedupedPageIds.join(", ");
62397
+ const text2 = `Authorised batch destructive writes for ${N} page${N === 1 ? "" : "s"}.
62398
+ Pages: ${idsLine}
62399
+ Max operations: ${resolvedMax}
62400
+ Expires: ${expiresIso}
62401
+ Audit ID: ${batch.auditId}
62402
+
62403
+ Pass the batch_token below to subsequent update_page / update_page_section / update_page_sections / delete_page calls. Validation failures (wrong page, expired, exhausted, etc.) fall through to the per-call confirmation flow.
62404
+
62405
+ batch_token: ${batch.token}` + echo;
62406
+ return {
62407
+ content: [{ type: "text", text: text2 }],
62408
+ structuredContent: {
62409
+ kind: "batch_authorised",
62410
+ batch_token: batch.token,
62411
+ audit_id: batch.auditId,
62412
+ expires_at: expiresIso,
62413
+ authorised_page_ids: batch.authorisedPageIds,
62414
+ remaining_operations: batch.remainingOperations
62415
+ }
62416
+ };
62417
+ } catch (err) {
62418
+ if (err instanceof SoftConfirmationRequiredError) {
62419
+ return formatSoftConfirmationResult(err, { pageId: err.pageId });
62420
+ }
62421
+ if (err instanceof BatchMintRateLimitedError) {
62422
+ return toolError(err);
62423
+ }
62424
+ return toolErrorWithContext(err, {
62425
+ operation: "authorise_destructive_writes",
62426
+ profile: config3.profile
62427
+ });
62428
+ }
62429
+ }
62430
+ );
61946
62431
  server.registerTool(
61947
62432
  "update_page",
61948
62433
  {
@@ -61971,7 +62456,10 @@ ${truncated}${truncationNote(origLen)}`
61971
62456
  allow_raw_html: external_exports.boolean().default(false).describe("Allow raw HTML passthrough inside markdown bodies (disabled by default)."),
61972
62457
  confluence_base_url: external_exports.string().url().optional().describe("Override the Confluence base URL used by the link rewriter. Defaults to the configured Confluence URL."),
61973
62458
  source: sourceSchema,
61974
- confirm_token: external_exports.string().optional().describe("Soft-confirmation token from a prior SOFT_CONFIRMATION_REQUIRED response. Single-use; bound to this exact diff and page version.")
62459
+ confirm_token: external_exports.string().optional().describe("Soft-confirmation token from a prior SOFT_CONFIRMATION_REQUIRED response. Single-use; bound to this exact diff and page version."),
62460
+ batch_token: external_exports.string().optional().describe(
62461
+ "Batch authorisation token from a prior authorise_destructive_writes call. Bypasses the per-call confirmation gate when valid for this page_id. Validation failures fall through to the per-call confirm_token / soft-confirm flow."
62462
+ )
61975
62463
  },
61976
62464
  // v6.6.2 §3.1 — declared so spec-compliant clients forward our
61977
62465
  // structuredContent payload to the agent (the soft-confirmation
@@ -61980,9 +62468,11 @@ ${truncated}${truncationNote(origLen)}`
61980
62468
  outputSchema: writeOutputSchema,
61981
62469
  annotations: { destructiveHint: false, idempotentHint: false }
61982
62470
  },
61983
- async ({ page_id, title, version: version2, body, version_message, confirm_deletions, replace_body, confirm_shrinkage, confirm_structure_loss, allow_raw_html, confluence_base_url, source, confirm_token }) => {
62471
+ async ({ page_id, title, version: version2, body, version_message, confirm_deletions, replace_body, confirm_shrinkage, confirm_structure_loss, allow_raw_html, confluence_base_url, source, confirm_token, batch_token }) => {
61984
62472
  const blocked = writeGuard("update_page", config3);
61985
62473
  if (blocked) return blocked;
62474
+ let batchReservationId;
62475
+ let dispatched = false;
61986
62476
  try {
61987
62477
  await checkSpaceAllowed({ pageId: page_id });
61988
62478
  const flagsSet = listDestructiveFlagsSet({
@@ -61999,36 +62489,44 @@ ${truncated}${truncationNote(origLen)}`
61999
62489
  const pageVersion = currentPage.version?.number ?? 0;
62000
62490
  if (flagsSet.length > 0) {
62001
62491
  const deletionSummary = confirm_deletions && body ? tryForecastDeletions(currentStorage, body, confluence_base_url ?? cfg.url) : null;
62002
- const diffHash = cloudId && pageVersion > 0 ? computeDiffHash(body ?? currentStorage, pageVersion) : void 0;
62003
- const tokenResult = await maybeConsumeConfirmToken({
62004
- confirm_token,
62005
- tool: "update_page",
62492
+ const batchAttempt = await tryBatchTokenForWrite({
62493
+ batch_token,
62006
62494
  cloudId,
62007
- pageId: page_id,
62008
- pageVersion,
62009
- diffHash
62495
+ pageId: page_id
62010
62496
  });
62011
- if (tokenResult === "invalid") {
62012
- throw new ConverterError(
62013
- "The confirmation token is no longer valid. Mint a new one by re-calling this tool without confirm_token, ask the user again, then retry with the new token.",
62014
- "CONFIRMATION_TOKEN_INVALID"
62015
- );
62016
- } else if (tokenResult === "no_token") {
62017
- await gateOperation(server, {
62497
+ batchReservationId = batchAttempt.batchReservationId;
62498
+ if (batchReservationId === void 0) {
62499
+ const diffHash = cloudId && pageVersion > 0 ? computeDiffHash(body ?? currentStorage, pageVersion) : void 0;
62500
+ const tokenResult = await maybeConsumeConfirmToken({
62501
+ confirm_token,
62018
62502
  tool: "update_page",
62019
- summary: `Update page ${page_id} with destructive flags?`,
62020
- details: {
62021
- page_id,
62022
- flags: flagsSet.join(","),
62023
- source: effectiveSource,
62024
- version: version2,
62025
- ...deletionSummary ? { deletionSummary } : {}
62026
- },
62027
62503
  cloudId,
62028
62504
  pageId: page_id,
62029
62505
  pageVersion,
62030
62506
  diffHash
62031
62507
  });
62508
+ if (tokenResult === "invalid") {
62509
+ throw new ConverterError(
62510
+ "The confirmation token is no longer valid. Mint a new one by re-calling this tool without confirm_token, ask the user again, then retry with the new token.",
62511
+ "CONFIRMATION_TOKEN_INVALID"
62512
+ );
62513
+ } else if (tokenResult === "no_token") {
62514
+ await gateOperation(server, {
62515
+ tool: "update_page",
62516
+ summary: `Update page ${page_id} with destructive flags?`,
62517
+ details: {
62518
+ page_id,
62519
+ flags: flagsSet.join(","),
62520
+ source: effectiveSource,
62521
+ version: version2,
62522
+ ...deletionSummary ? { deletionSummary } : {}
62523
+ },
62524
+ cloudId,
62525
+ pageId: page_id,
62526
+ pageVersion,
62527
+ diffHash
62528
+ });
62529
+ }
62032
62530
  }
62033
62531
  }
62034
62532
  const resolvedVersion = version2 === "current" ? currentPage.version?.number ?? 0 : version2;
@@ -62048,6 +62546,7 @@ ${truncated}${truncationNote(origLen)}`
62048
62546
  confluenceBaseUrl: confluence_base_url ?? cfg.url
62049
62547
  });
62050
62548
  const mergedVersionMessage = prepared.versionMessage && version_message ? `${version_message}; ${prepared.versionMessage}` : prepared.versionMessage || version_message || "";
62549
+ dispatched = true;
62051
62550
  const submitted = await safeSubmitPage({
62052
62551
  pageId: page_id,
62053
62552
  title,
@@ -62074,6 +62573,9 @@ ${truncated}${truncationNote(origLen)}`
62074
62573
  const badgeResult = await markPageUnverified(submitted.page.id, cfg);
62075
62574
  if (badgeResult.warning) warnings.push(badgeResult.warning);
62076
62575
  if (isTitleOnly) {
62576
+ if (batchReservationId !== void 0) {
62577
+ finaliseReservation(batchReservationId);
62578
+ }
62077
62579
  const titleOnlyResult = toolResult(
62078
62580
  appendWarnings(`Updated: ${submitted.page.title} (ID: ${submitted.page.id}, version: ${submitted.newVersion}, title only, body unchanged)`, warnings) + echo
62079
62581
  );
@@ -62088,6 +62590,9 @@ ${truncated}${truncationNote(origLen)}`
62088
62590
  };
62089
62591
  }
62090
62592
  const removalNote = submitted.deletedTokens.length > 0 ? `; removed ${submitted.deletedTokens.length} preserved macro${submitted.deletedTokens.length === 1 ? "" : "s"}: ${submitted.deletedTokens.map((t) => t.fingerprint).join(", ")}` : "";
62593
+ if (batchReservationId !== void 0) {
62594
+ finaliseReservation(batchReservationId);
62595
+ }
62091
62596
  const bodyUpdateResult = toolResult(
62092
62597
  appendWarnings(`Updated: ${submitted.page.title} (ID: ${submitted.page.id}, version: ${submitted.newVersion}, body: ${submitted.oldLen}\u2192${submitted.newLen} chars${removalNote})`, warnings) + echo
62093
62598
  );
@@ -62103,6 +62608,9 @@ ${truncated}${truncationNote(origLen)}`
62103
62608
  }
62104
62609
  };
62105
62610
  } catch (err) {
62611
+ if (batchReservationId !== void 0 && !dispatched) {
62612
+ refundReservation(batchReservationId);
62613
+ }
62106
62614
  if (err instanceof SoftConfirmationRequiredError) {
62107
62615
  return formatSoftConfirmationResult(err, { pageId: page_id });
62108
62616
  }
@@ -62125,7 +62633,10 @@ ${truncated}${truncationNote(origLen)}`
62125
62633
  "The page version number from your most recent get_page call. Required unless EPIMETHIAN_LEGACY_DELETE_WITHOUT_VERSION=true is set; omitting it under the legacy flag emits a stderr warning."
62126
62634
  ),
62127
62635
  source: sourceSchema,
62128
- confirm_token: external_exports.string().optional().describe("Soft-confirmation token from a prior SOFT_CONFIRMATION_REQUIRED response. Single-use; bound to this exact page version.")
62636
+ confirm_token: external_exports.string().optional().describe("Soft-confirmation token from a prior SOFT_CONFIRMATION_REQUIRED response. Single-use; bound to this exact page version."),
62637
+ batch_token: external_exports.string().optional().describe(
62638
+ "Batch authorisation token from a prior authorise_destructive_writes call. Bypasses the per-call confirmation gate when valid for this page_id. Validation failures fall through to the per-call confirm_token / soft-confirm flow."
62639
+ )
62129
62640
  },
62130
62641
  // v6.6.2 §3.1 — declared so spec-compliant clients forward our
62131
62642
  // structuredContent payload (especially the soft-confirm token)
@@ -62133,9 +62644,11 @@ ${truncated}${truncationNote(origLen)}`
62133
62644
  outputSchema: deleteOutputSchema,
62134
62645
  annotations: { destructiveHint: true, idempotentHint: true }
62135
62646
  },
62136
- async ({ page_id, version: version2, source, confirm_token }) => {
62647
+ async ({ page_id, version: version2, source, confirm_token, batch_token }) => {
62137
62648
  const blocked = writeGuard("delete_page", config3);
62138
62649
  if (blocked) return blocked;
62650
+ let batchReservationId;
62651
+ let dispatched = false;
62139
62652
  try {
62140
62653
  await checkSpaceAllowed({ pageId: page_id });
62141
62654
  const effectiveSource = validateSource(source, ["delete_page"]);
@@ -62156,35 +62669,44 @@ ${truncated}${truncationNote(origLen)}`
62156
62669
  const cloudId = cfg.sealedCloudId;
62157
62670
  const pageVersion = version2 ?? 0;
62158
62671
  const diffHash = cloudId && pageVersion > 0 ? computeDiffHash("", pageVersion) : void 0;
62159
- const tokenResult = await maybeConsumeConfirmToken({
62160
- confirm_token,
62161
- tool: "delete_page",
62672
+ const batchAttempt = await tryBatchTokenForWrite({
62673
+ batch_token,
62162
62674
  cloudId,
62163
- pageId: page_id,
62164
- pageVersion,
62165
- diffHash
62675
+ pageId: page_id
62166
62676
  });
62167
- if (tokenResult === "invalid") {
62168
- throw new ConverterError(
62169
- "The confirmation token is no longer valid. Mint a new one by re-calling this tool without confirm_token, ask the user again, then retry with the new token.",
62170
- "CONFIRMATION_TOKEN_INVALID"
62171
- );
62172
- } else if (tokenResult === "no_token") {
62173
- await gateOperation(server, {
62677
+ batchReservationId = batchAttempt.batchReservationId;
62678
+ if (batchReservationId === void 0) {
62679
+ const tokenResult = await maybeConsumeConfirmToken({
62680
+ confirm_token,
62174
62681
  tool: "delete_page",
62175
- summary: `Delete page ${page_id}?`,
62176
- details: {
62177
- page_id,
62178
- version: version2 ?? "(legacy: unversioned)",
62179
- source: effectiveSource
62180
- },
62181
62682
  cloudId,
62182
62683
  pageId: page_id,
62183
62684
  pageVersion,
62184
62685
  diffHash
62185
62686
  });
62687
+ if (tokenResult === "invalid") {
62688
+ throw new ConverterError(
62689
+ "The confirmation token is no longer valid. Mint a new one by re-calling this tool without confirm_token, ask the user again, then retry with the new token.",
62690
+ "CONFIRMATION_TOKEN_INVALID"
62691
+ );
62692
+ } else if (tokenResult === "no_token") {
62693
+ await gateOperation(server, {
62694
+ tool: "delete_page",
62695
+ summary: `Delete page ${page_id}?`,
62696
+ details: {
62697
+ page_id,
62698
+ version: version2 ?? "(legacy: unversioned)",
62699
+ source: effectiveSource
62700
+ },
62701
+ cloudId,
62702
+ pageId: page_id,
62703
+ pageVersion,
62704
+ diffHash
62705
+ });
62706
+ }
62186
62707
  }
62187
62708
  writeBudget.consume();
62709
+ dispatched = true;
62188
62710
  await deletePage(page_id, version2);
62189
62711
  if (cloudId !== void 0) {
62190
62712
  invalidateForPage(cloudId, page_id);
@@ -62196,6 +62718,9 @@ ${truncated}${truncationNote(origLen)}`
62196
62718
  ...version2 !== void 0 ? { oldVersion: version2 } : {},
62197
62719
  source: effectiveSource
62198
62720
  });
62721
+ if (batchReservationId !== void 0) {
62722
+ finaliseReservation(batchReservationId);
62723
+ }
62199
62724
  const deletedResult = toolResult(`Deleted page ${page_id}` + echo);
62200
62725
  return {
62201
62726
  ...deletedResult,
@@ -62206,6 +62731,9 @@ ${truncated}${truncationNote(origLen)}`
62206
62731
  }
62207
62732
  };
62208
62733
  } catch (err) {
62734
+ if (batchReservationId !== void 0 && !dispatched) {
62735
+ refundReservation(batchReservationId);
62736
+ }
62209
62737
  if (err instanceof SoftConfirmationRequiredError) {
62210
62738
  return formatSoftConfirmationResult(err, { pageId: page_id });
62211
62739
  }
@@ -62246,16 +62774,23 @@ ${truncated}${truncationNote(origLen)}`
62246
62774
  ),
62247
62775
  version_message: external_exports.string().optional().describe("Optional version comment"),
62248
62776
  confirm_deletions: external_exports.boolean().default(false).describe("Set to true to acknowledge that your markdown removes preserved macros, emoticons, or rich elements from this section. Required when any preserved element would be deleted."),
62249
- confirm_token: external_exports.string().optional().describe("Soft-confirmation token from a prior SOFT_CONFIRMATION_REQUIRED response. Single-use; bound to this exact diff and page version.")
62777
+ confirm_shrinkage: external_exports.boolean().default(false).describe("Set to true to acknowledge a large body reduction. The shrinkage guard is measured against the WHOLE page (not the isolated section), so a small edit to a short section will not trip it; this flag is only needed for a genuinely large page-level reduction."),
62778
+ confirm_structure_loss: external_exports.boolean().default(false).describe("Set to true to acknowledge a large drop in heading count, measured against the whole page."),
62779
+ confirm_token: external_exports.string().optional().describe("Soft-confirmation token from a prior SOFT_CONFIRMATION_REQUIRED response. Single-use; bound to this exact diff and page version."),
62780
+ batch_token: external_exports.string().optional().describe(
62781
+ "Batch authorisation token from a prior authorise_destructive_writes call. Bypasses the per-call confirmation gate when valid for this page_id. Validation failures fall through to the per-call confirm_token / soft-confirm flow."
62782
+ )
62250
62783
  },
62251
62784
  // v6.6.2 §3.1 — declared so spec-compliant clients forward our
62252
62785
  // structuredContent payload to the agent.
62253
62786
  outputSchema: writeOutputSchema,
62254
62787
  annotations: { destructiveHint: false, idempotentHint: false }
62255
62788
  },
62256
- async ({ page_id, section, body, find_replace, version: version2, version_message, confirm_deletions, confirm_token }) => {
62789
+ async ({ page_id, section, body, find_replace, version: version2, version_message, confirm_deletions, confirm_shrinkage, confirm_structure_loss, confirm_token, batch_token }) => {
62257
62790
  const blocked = writeGuard("update_page_section", config3);
62258
62791
  if (blocked) return blocked;
62792
+ let batchReservationId;
62793
+ let dispatched = false;
62259
62794
  try {
62260
62795
  const hasBody = body !== void 0;
62261
62796
  const hasFindReplace = find_replace !== void 0 && find_replace.length > 0;
@@ -62306,6 +62841,7 @@ ${truncated}${truncationNote(origLen)}`
62306
62841
  )
62307
62842
  );
62308
62843
  }
62844
+ dispatched = true;
62309
62845
  const submitted2 = await safeSubmitPage({
62310
62846
  pageId: page_id,
62311
62847
  title: page.title,
@@ -62342,37 +62878,50 @@ ${truncated}${truncationNote(origLen)}`
62342
62878
  }
62343
62879
  };
62344
62880
  }
62345
- if (confirm_deletions) {
62346
- const deletionSummary = tryForecastDeletions(currentSectionBody, body, cfg.url);
62347
- const diffHash = cloudId && pageVersion > 0 ? computeDiffHash(body ?? currentSectionBody, pageVersion) : void 0;
62348
- const tokenResult = await maybeConsumeConfirmToken({
62349
- confirm_token,
62350
- tool: "update_page_section",
62881
+ const sectionFlagsSet = listDestructiveFlagsSet({
62882
+ confirmShrinkage: confirm_shrinkage,
62883
+ confirmStructureLoss: confirm_structure_loss,
62884
+ confirmDeletions: confirm_deletions
62885
+ });
62886
+ if (sectionFlagsSet.length > 0) {
62887
+ const batchAttempt = await tryBatchTokenForWrite({
62888
+ batch_token,
62351
62889
  cloudId,
62352
- pageId: page_id,
62353
- pageVersion,
62354
- diffHash
62890
+ pageId: page_id
62355
62891
  });
62356
- if (tokenResult === "invalid") {
62357
- throw new ConverterError(
62358
- "The confirmation token is no longer valid. Mint a new one by re-calling this tool without confirm_token, ask the user again, then retry with the new token.",
62359
- "CONFIRMATION_TOKEN_INVALID"
62360
- );
62361
- } else if (tokenResult === "no_token") {
62362
- await gateOperation(server, {
62892
+ batchReservationId = batchAttempt.batchReservationId;
62893
+ if (batchReservationId === void 0) {
62894
+ const deletionSummary = confirm_deletions && body ? tryForecastDeletions(currentSectionBody, body, cfg.url) : null;
62895
+ const diffHash = cloudId && pageVersion > 0 ? computeDiffHash(body ?? currentSectionBody, pageVersion) : void 0;
62896
+ const tokenResult = await maybeConsumeConfirmToken({
62897
+ confirm_token,
62363
62898
  tool: "update_page_section",
62364
- summary: `Update section "${section}" in page ${page_id} with confirm_deletions?`,
62365
- details: {
62366
- page_id,
62367
- section,
62368
- source: "confirm_deletions",
62369
- ...deletionSummary ? { deletionSummary } : {}
62370
- },
62371
62899
  cloudId,
62372
62900
  pageId: page_id,
62373
62901
  pageVersion,
62374
62902
  diffHash
62375
62903
  });
62904
+ if (tokenResult === "invalid") {
62905
+ throw new ConverterError(
62906
+ "The confirmation token is no longer valid. Mint a new one by re-calling this tool without confirm_token, ask the user again, then retry with the new token.",
62907
+ "CONFIRMATION_TOKEN_INVALID"
62908
+ );
62909
+ } else if (tokenResult === "no_token") {
62910
+ await gateOperation(server, {
62911
+ tool: "update_page_section",
62912
+ summary: `Update section "${section}" in page ${page_id} with ${sectionFlagsSet.join(", ")}?`,
62913
+ details: {
62914
+ page_id,
62915
+ section,
62916
+ flags: sectionFlagsSet.join(","),
62917
+ ...deletionSummary ? { deletionSummary } : {}
62918
+ },
62919
+ cloudId,
62920
+ pageId: page_id,
62921
+ pageVersion,
62922
+ diffHash
62923
+ });
62924
+ }
62376
62925
  }
62377
62926
  }
62378
62927
  const prepared = await safePrepareBody({
@@ -62380,6 +62929,10 @@ ${truncated}${truncationNote(origLen)}`
62380
62929
  currentBody: currentSectionBody,
62381
62930
  scope: "section",
62382
62931
  confirmDeletions: confirm_deletions || void 0,
62932
+ confirmShrinkage: confirm_shrinkage,
62933
+ confirmStructureLoss: confirm_structure_loss,
62934
+ // Measure shrink/structure/floor guards against the whole page.
62935
+ fullPageBody: fullBody,
62383
62936
  confluenceBaseUrl: cfg.url
62384
62937
  });
62385
62938
  const newFullBody = replaceSection(fullBody, section, prepared.finalStorage);
@@ -62391,6 +62944,7 @@ ${truncated}${truncationNote(origLen)}`
62391
62944
  );
62392
62945
  }
62393
62946
  const mergedVersionMessage = prepared.versionMessage && version_message ? `${version_message}; ${prepared.versionMessage}` : prepared.versionMessage || version_message || "";
62947
+ dispatched = true;
62394
62948
  const submitted = await safeSubmitPage({
62395
62949
  pageId: page_id,
62396
62950
  title: page.title,
@@ -62401,6 +62955,11 @@ ${truncated}${truncationNote(origLen)}`
62401
62955
  deletedTokens: prepared.deletedTokens,
62402
62956
  operation: "update_page_section",
62403
62957
  clientLabel: getClientLabel(server),
62958
+ // Recorded for the destructive-flag audit / version-message suffix;
62959
+ // guards already ran in safePrepareBody (page-relative).
62960
+ confirmShrinkage: confirm_shrinkage,
62961
+ confirmStructureLoss: confirm_structure_loss,
62962
+ confirmDeletions: confirm_deletions || void 0,
62404
62963
  cloudId
62405
62964
  });
62406
62965
  const warnings = [];
@@ -62409,6 +62968,9 @@ ${truncated}${truncationNote(origLen)}`
62409
62968
  const badgeResult = await markPageUnverified(submitted.page.id, cfg);
62410
62969
  if (badgeResult.warning) warnings.push(badgeResult.warning);
62411
62970
  const removalNote = submitted.deletedTokens.length > 0 ? `; removed ${submitted.deletedTokens.length} preserved macro${submitted.deletedTokens.length === 1 ? "" : "s"}: ${submitted.deletedTokens.map((t) => t.fingerprint).join(", ")}` : "";
62971
+ if (batchReservationId !== void 0) {
62972
+ finaliseReservation(batchReservationId);
62973
+ }
62412
62974
  const sectionBodyResult = toolResult(
62413
62975
  appendWarnings(`Updated section "${section}" in: ${submitted.page.title} (ID: ${submitted.page.id}, version: ${submitted.newVersion}${removalNote})`, warnings) + echo
62414
62976
  );
@@ -62424,6 +62986,9 @@ ${truncated}${truncationNote(origLen)}`
62424
62986
  }
62425
62987
  };
62426
62988
  } catch (err) {
62989
+ if (batchReservationId !== void 0 && !dispatched) {
62990
+ refundReservation(batchReservationId);
62991
+ }
62427
62992
  if (err instanceof SoftConfirmationRequiredError) {
62428
62993
  return formatSoftConfirmationResult(err, { pageId: page_id });
62429
62994
  }
@@ -62449,7 +63014,12 @@ ${truncated}${truncationNote(origLen)}`
62449
63014
  confirm_deletions: external_exports.boolean().default(false).describe(
62450
63015
  "Set to true to acknowledge that the aggregated set of sections removes preserved macros, emoticons, or rich elements. Required when ANY section would delete a preserved element. The deletion-summary gate fires once on the AGGREGATE \u2014 a caller cannot bypass the gate by spreading deletions across sections."
62451
63016
  ),
63017
+ confirm_shrinkage: external_exports.boolean().default(false).describe("Set to true to acknowledge a large body reduction. Each section's shrinkage is measured against the WHOLE page, so small edits to short sections will not trip it; needed only for a genuinely large page-level reduction."),
63018
+ confirm_structure_loss: external_exports.boolean().default(false).describe("Set to true to acknowledge a large drop in heading count, measured against the whole page."),
62452
63019
  confirm_token: external_exports.string().optional().describe("Soft-confirmation token from a prior SOFT_CONFIRMATION_REQUIRED response. Single-use; bound to this exact diff and page version."),
63020
+ batch_token: external_exports.string().optional().describe(
63021
+ "Batch authorisation token from a prior authorise_destructive_writes call. Bypasses the per-call confirmation gate when valid for this page_id. Validation failures fall through to the per-call confirm_token / soft-confirm flow."
63022
+ ),
62453
63023
  sections: external_exports.array(
62454
63024
  external_exports.object({
62455
63025
  section: external_exports.string().describe("Heading text identifying the section to replace"),
@@ -62463,9 +63033,11 @@ ${truncated}${truncationNote(origLen)}`
62463
63033
  },
62464
63034
  annotations: { destructiveHint: false, idempotentHint: false }
62465
63035
  },
62466
- async ({ page_id, version: version2, version_message, confirm_deletions, sections, confirm_token }) => {
63036
+ async ({ page_id, version: version2, version_message, confirm_deletions, confirm_shrinkage, confirm_structure_loss, sections, confirm_token, batch_token }) => {
62467
63037
  const blocked = writeGuard("update_page_sections", config3);
62468
63038
  if (blocked) return blocked;
63039
+ let batchReservationId;
63040
+ let dispatched = false;
62469
63041
  try {
62470
63042
  await checkSpaceAllowed({ pageId: page_id });
62471
63043
  const cfg = await getConfig();
@@ -62479,78 +63051,96 @@ ${truncated}${truncationNote(origLen)}`
62479
63051
  `Could not resolve current version for page ${page_id} (server returned no version metadata)`
62480
63052
  );
62481
63053
  }
62482
- if (confirm_deletions) {
62483
- const summed = {
62484
- tocs: 0,
62485
- links: 0,
62486
- structuredMacros: 0,
62487
- codeMacros: 0,
62488
- plainElements: 0,
62489
- other: 0
62490
- };
62491
- let any = false;
62492
- for (const s of sections) {
62493
- let currentSectionBody = null;
62494
- try {
62495
- currentSectionBody = extractSectionBody(fullBody, s.section);
62496
- } catch {
62497
- currentSectionBody = null;
62498
- }
62499
- if (currentSectionBody === null) continue;
62500
- const summary = tryForecastDeletions(
62501
- currentSectionBody,
62502
- s.body,
62503
- cfg.url
62504
- );
62505
- if (summary !== null) {
62506
- summed.tocs += summary.tocs;
62507
- summed.links += summary.links;
62508
- summed.structuredMacros += summary.structuredMacros;
62509
- summed.codeMacros += summary.codeMacros;
62510
- summed.plainElements += summary.plainElements;
62511
- summed.other += summary.other;
62512
- any = true;
62513
- }
62514
- }
62515
- const aggregateBody = sections.map((s) => s.body).join("\n");
62516
- const diffHash = cloudId && pageVersion > 0 ? computeDiffHash(aggregateBody, pageVersion) : void 0;
62517
- const tokenResult = await maybeConsumeConfirmToken({
62518
- confirm_token,
62519
- tool: "update_page_sections",
63054
+ const sectionsFlagsSet = listDestructiveFlagsSet({
63055
+ confirmShrinkage: confirm_shrinkage,
63056
+ confirmStructureLoss: confirm_structure_loss,
63057
+ confirmDeletions: confirm_deletions
63058
+ });
63059
+ if (sectionsFlagsSet.length > 0) {
63060
+ const batchAttempt = await tryBatchTokenForWrite({
63061
+ batch_token,
62520
63062
  cloudId,
62521
- pageId: page_id,
62522
- pageVersion,
62523
- diffHash
63063
+ pageId: page_id
62524
63064
  });
62525
- if (tokenResult === "invalid") {
62526
- throw new ConverterError(
62527
- "The confirmation token is no longer valid. Mint a new one by re-calling this tool without confirm_token, ask the user again, then retry with the new token.",
62528
- "CONFIRMATION_TOKEN_INVALID"
62529
- );
62530
- } else if (tokenResult === "no_token") {
62531
- await gateOperation(server, {
63065
+ batchReservationId = batchAttempt.batchReservationId;
63066
+ if (batchReservationId === void 0) {
63067
+ const summed = {
63068
+ tocs: 0,
63069
+ links: 0,
63070
+ structuredMacros: 0,
63071
+ codeMacros: 0,
63072
+ plainElements: 0,
63073
+ other: 0
63074
+ };
63075
+ let any = false;
63076
+ if (confirm_deletions) {
63077
+ for (const s of sections) {
63078
+ let currentSectionBody = null;
63079
+ try {
63080
+ currentSectionBody = extractSectionBody(fullBody, s.section);
63081
+ } catch {
63082
+ currentSectionBody = null;
63083
+ }
63084
+ if (currentSectionBody === null) continue;
63085
+ const summary = tryForecastDeletions(
63086
+ currentSectionBody,
63087
+ s.body,
63088
+ cfg.url
63089
+ );
63090
+ if (summary !== null) {
63091
+ summed.tocs += summary.tocs;
63092
+ summed.links += summary.links;
63093
+ summed.structuredMacros += summary.structuredMacros;
63094
+ summed.codeMacros += summary.codeMacros;
63095
+ summed.plainElements += summary.plainElements;
63096
+ summed.other += summary.other;
63097
+ any = true;
63098
+ }
63099
+ }
63100
+ }
63101
+ const aggregateBody = sections.map((s) => s.body).join("\n");
63102
+ const diffHash = cloudId && pageVersion > 0 ? computeDiffHash(aggregateBody, pageVersion) : void 0;
63103
+ const tokenResult = await maybeConsumeConfirmToken({
63104
+ confirm_token,
62532
63105
  tool: "update_page_sections",
62533
- summary: `Update ${sections.length} section${sections.length === 1 ? "" : "s"} in page ${page_id} with confirm_deletions?`,
62534
- details: {
62535
- page_id,
62536
- section_count: sections.length,
62537
- source: "confirm_deletions",
62538
- ...any ? { deletionSummary: summed } : {}
62539
- },
62540
63106
  cloudId,
62541
63107
  pageId: page_id,
62542
63108
  pageVersion,
62543
63109
  diffHash
62544
63110
  });
63111
+ if (tokenResult === "invalid") {
63112
+ throw new ConverterError(
63113
+ "The confirmation token is no longer valid. Mint a new one by re-calling this tool without confirm_token, ask the user again, then retry with the new token.",
63114
+ "CONFIRMATION_TOKEN_INVALID"
63115
+ );
63116
+ } else if (tokenResult === "no_token") {
63117
+ await gateOperation(server, {
63118
+ tool: "update_page_sections",
63119
+ summary: `Update ${sections.length} section${sections.length === 1 ? "" : "s"} in page ${page_id} with ${sectionsFlagsSet.join(", ")}?`,
63120
+ details: {
63121
+ page_id,
63122
+ section_count: sections.length,
63123
+ flags: sectionsFlagsSet.join(","),
63124
+ ...any ? { deletionSummary: summed } : {}
63125
+ },
63126
+ cloudId,
63127
+ pageId: page_id,
63128
+ pageVersion,
63129
+ diffHash
63130
+ });
63131
+ }
62545
63132
  }
62546
63133
  }
62547
63134
  const prepared = await safePrepareMultiSectionBody({
62548
63135
  currentStorage: fullBody,
62549
63136
  sections,
62550
63137
  confirmDeletions: confirm_deletions,
63138
+ confirmShrinkage: confirm_shrinkage,
63139
+ confirmStructureLoss: confirm_structure_loss,
62551
63140
  confluenceBaseUrl: cfg.url
62552
63141
  });
62553
63142
  const mergedVersionMessage = prepared.versionMessage && version_message ? `${version_message}; ${prepared.versionMessage}` : prepared.versionMessage || version_message || "";
63143
+ dispatched = true;
62554
63144
  const submitted = await safeSubmitPage({
62555
63145
  pageId: page_id,
62556
63146
  title: page.title,
@@ -62563,6 +63153,8 @@ ${truncated}${truncationNote(origLen)}`
62563
63153
  operation: "update_page_section",
62564
63154
  clientLabel: getClientLabel(server),
62565
63155
  confirmDeletions: confirm_deletions,
63156
+ confirmShrinkage: confirm_shrinkage,
63157
+ confirmStructureLoss: confirm_structure_loss,
62566
63158
  cloudId
62567
63159
  });
62568
63160
  const warnings = [];
@@ -62572,6 +63164,9 @@ ${truncated}${truncationNote(origLen)}`
62572
63164
  if (badgeResult.warning) warnings.push(badgeResult.warning);
62573
63165
  const removalNote = submitted.deletedTokens.length > 0 ? `; removed ${submitted.deletedTokens.length} preserved macro${submitted.deletedTokens.length === 1 ? "" : "s"}: ${submitted.deletedTokens.map((t) => t.fingerprint).join(", ")}` : "";
62574
63166
  const sectionList = prepared.perSectionResults.map((r) => `"${r.section}"`).join(", ");
63167
+ if (batchReservationId !== void 0) {
63168
+ finaliseReservation(batchReservationId);
63169
+ }
62575
63170
  return toolResult(
62576
63171
  appendWarnings(
62577
63172
  `Updated ${prepared.perSectionResults.length} section${prepared.perSectionResults.length === 1 ? "" : "s"} (${sectionList}) in: ${submitted.page.title} (ID: ${submitted.page.id}, version: ${submitted.newVersion}${removalNote})`,
@@ -62579,6 +63174,9 @@ ${truncated}${truncationNote(origLen)}`
62579
63174
  ) + echo
62580
63175
  );
62581
63176
  } catch (err) {
63177
+ if (batchReservationId !== void 0 && !dispatched) {
63178
+ refundReservation(batchReservationId);
63179
+ }
62582
63180
  if (err instanceof SoftConfirmationRequiredError) {
62583
63181
  return formatSoftConfirmationResult(err, { pageId: page_id });
62584
63182
  }
@@ -63013,12 +63611,18 @@ ${truncated}`);
63013
63611
  "Name for the diagram file (e.g., 'architecture.drawio'). Will have .drawio appended if not present."
63014
63612
  ),
63015
63613
  append: external_exports.boolean().default(true).describe(
63016
- "If true, appends the diagram to existing page content. If false, replaces the page body."
63614
+ "If true, appends the diagram to the end of the page. If false, replaces the page body. Ignored when after_section or return_macro_only is set."
63615
+ ),
63616
+ after_section: external_exports.string().optional().describe(
63617
+ "Heading text of a section to place the diagram in. The diagram is inserted at the END of that section's content (before the next heading) instead of at the end of the page. Use headings_only / get_page to find section names. Takes precedence over `append`."
63618
+ ),
63619
+ return_macro_only: external_exports.boolean().default(false).describe(
63620
+ "If true, upload the diagram as an attachment but DO NOT modify the page body; instead return the draw.io macro storage markup so you can place it yourself with update_page / update_page_section. Use this when you need precise positioning the other options can't express. Takes precedence over `after_section` and `append`. (The attachment is created either way; if you never embed the returned macro it is left orphaned.)"
63017
63621
  )
63018
63622
  },
63019
63623
  annotations: { destructiveHint: false, idempotentHint: false }
63020
63624
  },
63021
- async ({ page_id, diagram_xml, diagram_name, append }) => {
63625
+ async ({ page_id, diagram_xml, diagram_name, append, after_section, return_macro_only }) => {
63022
63626
  const blocked = writeGuard("add_drawio_diagram", config3);
63023
63627
  if (blocked) return blocked;
63024
63628
  try {
@@ -63053,15 +63657,47 @@ ${truncated}`);
63053
63657
  ].join("\n");
63054
63658
  const current = await getPage(page_id, true);
63055
63659
  const existingBody = current.body?.storage?.value ?? current.body?.value ?? "";
63056
- const newBody = append ? `${existingBody}
63660
+ if (return_macro_only) {
63661
+ return toolResult(
63662
+ `Diagram "${filename}" uploaded to page ${current.title} (ID: ${page_id}, attachment ID: ${attachmentId}, macro ID: ${macroId}). The page body was NOT modified. Embed the diagram by inserting this macro where you want it (its baseUrl/pageId/diagramName are bound to this page):
63663
+
63664
+ ${macro}${echo}`
63665
+ );
63666
+ }
63667
+ let newBody;
63668
+ let placementNote = "";
63669
+ if (after_section !== void 0) {
63670
+ const sectionBody = extractSectionBody(existingBody, after_section);
63671
+ if (sectionBody === null) {
63672
+ return toolError(
63673
+ new Error(
63674
+ `Section "${after_section}" not found. Use headings_only to see available sections.`
63675
+ )
63676
+ );
63677
+ }
63678
+ const spliced = replaceSection(
63679
+ existingBody,
63680
+ after_section,
63681
+ `${sectionBody}
63682
+ ${macro}`
63683
+ );
63684
+ if (spliced === null) {
63685
+ return toolError(
63686
+ new Error(
63687
+ `Section "${after_section}" not found. Use headings_only to see available sections.`
63688
+ )
63689
+ );
63690
+ }
63691
+ newBody = spliced;
63692
+ placementNote = ` in section "${after_section}"`;
63693
+ } else {
63694
+ newBody = append ? `${existingBody}
63057
63695
  ${macro}` : macro;
63696
+ }
63058
63697
  const prepared = await safePrepareBody({
63059
63698
  body: newBody,
63060
63699
  currentBody: existingBody,
63061
63700
  scope: "full"
63062
- // append=true is additive but newBody already contains the concat,
63063
- // so "full" is correct: guards compare existingBody vs the complete
63064
- // new body, which is what we want for both branches.
63065
63701
  });
63066
63702
  const submitted = await safeSubmitPage({
63067
63703
  pageId: page_id,
@@ -63080,7 +63716,7 @@ ${macro}` : macro;
63080
63716
  const badgeResult = await markPageUnverified(submitted.page.id, config3);
63081
63717
  if (badgeResult.warning) warnings.push(badgeResult.warning);
63082
63718
  return toolResult(
63083
- appendWarnings(`Diagram "${filename}" added to page ${submitted.page.title} (ID: ${submitted.page.id}, version: ${submitted.newVersion}, attachment ID: ${attachmentId}, macro ID: ${macroId})`, warnings) + echo
63719
+ appendWarnings(`Diagram "${filename}" added to page ${submitted.page.title}${placementNote} (ID: ${submitted.page.id}, version: ${submitted.newVersion}, attachment ID: ${attachmentId}, macro ID: ${macroId})`, warnings) + echo
63084
63720
  );
63085
63721
  } catch (err) {
63086
63722
  return toolErrorWithContext(err, { operation: "add_drawio_diagram", resource: `page ${page_id}`, profile: config3.profile });
@@ -63867,7 +64503,7 @@ ${titleFenced}${echo2}`
63867
64503
  inputSchema: {}
63868
64504
  },
63869
64505
  async () => {
63870
- let text2 = `epimethian-mcp v${"6.7.1"}`;
64506
+ let text2 = `epimethian-mcp v${"6.9.0"}`;
63871
64507
  try {
63872
64508
  const pending = await getPendingUpdate();
63873
64509
  if (pending) {
@@ -63898,7 +64534,7 @@ ${label} update available: v${pending.current} \u2192 v${pending.latest}. Run \`
63898
64534
  const pending = await getPendingUpdate();
63899
64535
  if (!pending) {
63900
64536
  return toolResult(
63901
- `epimethian-mcp v${"6.7.1"} is already up to date.`
64537
+ `epimethian-mcp v${"6.9.0"} is already up to date.`
63902
64538
  );
63903
64539
  }
63904
64540
  const output = await performUpgrade(pending.latest);
@@ -63920,7 +64556,7 @@ async function startRecoveryServer(profile) {
63920
64556
  const server = new McpServer(
63921
64557
  {
63922
64558
  name: `confluence-${profile}-setup-needed`,
63923
- version: "6.7.1"
64559
+ version: "6.9.0"
63924
64560
  },
63925
64561
  {
63926
64562
  instructions: `The Confluence profile "${profile}" referenced by CONFLUENCE_PROFILE has no keychain entry, so no Confluence tools are available. Call the setup_profile tool for instructions to create it.`
@@ -63971,21 +64607,21 @@ async function main() {
63971
64607
  const serverName = config3.profile ? `confluence-${config3.profile}` : "confluence";
63972
64608
  const server = new McpServer({
63973
64609
  name: serverName,
63974
- version: "6.7.1"
64610
+ version: "6.9.0"
63975
64611
  });
63976
64612
  await registerTools(server, config3);
63977
64613
  const transport = new StdioServerTransport();
63978
64614
  await server.connect(transport);
63979
64615
  try {
63980
64616
  const pending = await getPendingUpdate();
63981
- if (pending && pending.current === "6.7.1") {
64617
+ if (pending && pending.current === "6.9.0") {
63982
64618
  console.error(
63983
64619
  `epimethian-mcp: update available: v${pending.current} \u2192 v${pending.latest} (${pending.type}). Run \`epimethian-mcp upgrade\` to install.`
63984
64620
  );
63985
64621
  }
63986
64622
  } catch {
63987
64623
  }
63988
- checkForUpdates("6.7.1").catch(() => {
64624
+ checkForUpdates("6.9.0").catch(() => {
63989
64625
  });
63990
64626
  }
63991
64627