@de-otio/epimethian-mcp 6.4.1 → 6.6.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.4.1"}`;
35484
+ const epimethianTag = `Epimethian v${"6.6.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.4.1"}`;
35505
+ const epimethianTag = `Epimethian v${"6.6.0"}`;
35506
35506
  const effectiveClient = cfg.attribution ? opts.clientLabel : void 0;
35507
35507
  let versionMessage;
35508
35508
  if (opts.versionMessage && effectiveClient)
@@ -36504,6 +36504,25 @@ var init_confluence_client = __esm({
36504
36504
  }
36505
36505
  });
36506
36506
 
36507
+ // src/server/converter/types.ts
36508
+ var ConverterError, SHRINKAGE_NOT_CONFIRMED, STRUCTURE_LOSS_NOT_CONFIRMED, EMPTY_BODY_REJECTED, CONTENT_FLOOR_BREACHED;
36509
+ var init_types2 = __esm({
36510
+ "src/server/converter/types.ts"() {
36511
+ "use strict";
36512
+ ConverterError = class extends Error {
36513
+ constructor(message, code2) {
36514
+ super(message);
36515
+ this.code = code2;
36516
+ this.name = "ConverterError";
36517
+ }
36518
+ };
36519
+ SHRINKAGE_NOT_CONFIRMED = "SHRINKAGE_NOT_CONFIRMED";
36520
+ STRUCTURE_LOSS_NOT_CONFIRMED = "STRUCTURE_LOSS_NOT_CONFIRMED";
36521
+ EMPTY_BODY_REJECTED = "EMPTY_BODY_REJECTED";
36522
+ CONTENT_FLOOR_BREACHED = "CONTENT_FLOOR_BREACHED";
36523
+ }
36524
+ });
36525
+
36507
36526
  // src/server/converter/tokeniser.ts
36508
36527
  function formatTokenId(n) {
36509
36528
  return "T" + String(n).padStart(4, "0");
@@ -36645,6 +36664,221 @@ var init_mutation_log = __esm({
36645
36664
  }
36646
36665
  });
36647
36666
 
36667
+ // src/server/confirmation-tokens.ts
36668
+ function clampTtl(ttlMs) {
36669
+ if (!Number.isFinite(ttlMs)) return DEFAULT_SOFT_CONFIRM_TTL_MS;
36670
+ if (ttlMs < TTL_MIN_MS) return TTL_MIN_MS;
36671
+ if (ttlMs > TTL_MAX_MS) return TTL_MAX_MS;
36672
+ return ttlMs;
36673
+ }
36674
+ function getMintLimit() {
36675
+ const raw = process.env.EPIMETHIAN_SOFT_CONFIRM_MINT_LIMIT;
36676
+ if (raw === void 0) return MAX_MINTS_PER_15_MIN;
36677
+ const n = parseInt(raw, 10);
36678
+ if (!Number.isFinite(n) || n < 0) return MAX_MINTS_PER_15_MIN;
36679
+ return n;
36680
+ }
36681
+ function getDefaultTtl() {
36682
+ const raw = process.env.EPIMETHIAN_SOFT_CONFIRM_TTL_MS;
36683
+ if (raw === void 0) return DEFAULT_SOFT_CONFIRM_TTL_MS;
36684
+ const n = parseInt(raw, 10);
36685
+ if (!Number.isFinite(n)) return DEFAULT_SOFT_CONFIRM_TTL_MS;
36686
+ return clampTtl(n);
36687
+ }
36688
+ function emitMint(meta) {
36689
+ for (const h of mintHandlers) {
36690
+ try {
36691
+ h(meta);
36692
+ } catch {
36693
+ }
36694
+ }
36695
+ }
36696
+ function emitValidate(meta) {
36697
+ for (const h of validateHandlers) {
36698
+ try {
36699
+ h(meta);
36700
+ } catch {
36701
+ }
36702
+ }
36703
+ }
36704
+ function sleepUntil(targetWallClockMs) {
36705
+ return new Promise((resolve2) => {
36706
+ const remaining = targetWallClockMs - Date.now();
36707
+ if (remaining <= 0) {
36708
+ resolve2();
36709
+ return;
36710
+ }
36711
+ setTimeout(resolve2, remaining);
36712
+ });
36713
+ }
36714
+ function pruneMintTimestamps(now) {
36715
+ const cutoff = now - MINT_WINDOW_MS;
36716
+ if (mintTimestamps.length === 0) return;
36717
+ if (mintTimestamps[0] >= cutoff) return;
36718
+ mintTimestamps = mintTimestamps.filter((ts) => ts >= cutoff);
36719
+ }
36720
+ function evictOldest() {
36721
+ let oldestKey;
36722
+ let oldestSeq = Infinity;
36723
+ for (const [k, v] of store.entries()) {
36724
+ if (v.insertSeq < oldestSeq) {
36725
+ oldestSeq = v.insertSeq;
36726
+ oldestKey = k;
36727
+ }
36728
+ }
36729
+ if (oldestKey === void 0) return;
36730
+ const entry = store.get(oldestKey);
36731
+ store.delete(oldestKey);
36732
+ emitValidate({
36733
+ auditId: entry.auditId,
36734
+ tool: entry.ctx.tool,
36735
+ cloudId: entry.ctx.cloudId,
36736
+ pageId: entry.ctx.pageId,
36737
+ outcome: "evicted"
36738
+ });
36739
+ }
36740
+ function mintToken(ctx, ttlMs) {
36741
+ const now = Date.now();
36742
+ pruneMintTimestamps(now);
36743
+ const limit = getMintLimit();
36744
+ if (limit > 0 && mintTimestamps.length >= limit) {
36745
+ const oldest = mintTimestamps[0];
36746
+ const waitMs = Math.max(0, oldest + MINT_WINDOW_MS - now);
36747
+ throw new SoftConfirmRateLimitedError(
36748
+ mintTimestamps.length,
36749
+ limit,
36750
+ waitMs
36751
+ );
36752
+ }
36753
+ while (store.size >= MAX_OUTSTANDING_TOKENS) {
36754
+ evictOldest();
36755
+ }
36756
+ const resolvedTtl = clampTtl(ttlMs ?? getDefaultTtl());
36757
+ const expiresAt = now + resolvedTtl;
36758
+ const auditId = (0, import_node_crypto4.randomUUID)();
36759
+ const tokenStr = (0, import_node_crypto4.randomBytes)(24).toString("base64url");
36760
+ store.set(tokenStr, {
36761
+ auditId,
36762
+ ctx: { ...ctx },
36763
+ expiresAt,
36764
+ insertSeq: ++insertSeqCounter
36765
+ });
36766
+ mintTimestamps.push(now);
36767
+ emitMint({
36768
+ auditId,
36769
+ tool: ctx.tool,
36770
+ cloudId: ctx.cloudId,
36771
+ pageId: ctx.pageId,
36772
+ pageVersion: ctx.pageVersion,
36773
+ expiresAt,
36774
+ outstanding: store.size
36775
+ });
36776
+ return { token: tokenStr, auditId, expiresAt };
36777
+ }
36778
+ async function validateToken(token, ctx) {
36779
+ const floorTarget = Date.now() + MIN_VALIDATE_FLOOR_MS;
36780
+ let outcome;
36781
+ let auditId;
36782
+ const entry = store.get(token);
36783
+ if (!entry) {
36784
+ outcome = "unknown";
36785
+ } else {
36786
+ auditId = entry.auditId;
36787
+ const now = Date.now();
36788
+ if (now >= entry.expiresAt) {
36789
+ store.delete(token);
36790
+ outcome = "expired";
36791
+ } else if (entry.ctx.tool !== ctx.tool || entry.ctx.cloudId !== ctx.cloudId || entry.ctx.pageId !== ctx.pageId || entry.ctx.pageVersion !== ctx.pageVersion || entry.ctx.diffHash !== ctx.diffHash) {
36792
+ outcome = "mismatch";
36793
+ } else {
36794
+ store.delete(token);
36795
+ const siblings = [];
36796
+ for (const [k, v] of store.entries()) {
36797
+ if (v.ctx.cloudId === entry.ctx.cloudId && v.ctx.pageId === entry.ctx.pageId) {
36798
+ siblings.push([k, v]);
36799
+ }
36800
+ }
36801
+ for (const [k, v] of siblings) {
36802
+ store.delete(k);
36803
+ emitValidate({
36804
+ auditId: v.auditId,
36805
+ tool: v.ctx.tool,
36806
+ cloudId: v.ctx.cloudId,
36807
+ pageId: v.ctx.pageId,
36808
+ outcome: "stale"
36809
+ });
36810
+ }
36811
+ outcome = "ok";
36812
+ }
36813
+ }
36814
+ emitValidate({
36815
+ auditId,
36816
+ tool: ctx.tool,
36817
+ cloudId: ctx.cloudId,
36818
+ pageId: ctx.pageId,
36819
+ outcome
36820
+ });
36821
+ await sleepUntil(floorTarget);
36822
+ return outcome === "ok" ? "ok" : "invalid";
36823
+ }
36824
+ function invalidateForPage(cloudId, pageId) {
36825
+ const victims = [];
36826
+ for (const [k, v] of store.entries()) {
36827
+ if (v.ctx.cloudId === cloudId && v.ctx.pageId === pageId) {
36828
+ victims.push([k, v]);
36829
+ }
36830
+ }
36831
+ for (const [k, v] of victims) {
36832
+ store.delete(k);
36833
+ emitValidate({
36834
+ auditId: v.auditId,
36835
+ tool: v.ctx.tool,
36836
+ cloudId: v.ctx.cloudId,
36837
+ pageId: v.ctx.pageId,
36838
+ outcome: "stale"
36839
+ });
36840
+ }
36841
+ }
36842
+ function computeDiffHash(canonicalStorageXml, pageVersion) {
36843
+ return (0, import_node_crypto4.createHash)("sha256").update(`${canonicalStorageXml}
36844
+ ${pageVersion}`).digest("hex");
36845
+ }
36846
+ var import_node_crypto4, DEFAULT_SOFT_CONFIRM_TTL_MS, TTL_MIN_MS, TTL_MAX_MS, MAX_OUTSTANDING_TOKENS, MAX_MINTS_PER_15_MIN, MINT_WINDOW_MS, MIN_VALIDATE_FLOOR_MS, SOFT_CONFIRM_RATE_LIMITED, SoftConfirmRateLimitedError, store, mintTimestamps, insertSeqCounter, mintHandlers, validateHandlers;
36847
+ var init_confirmation_tokens = __esm({
36848
+ "src/server/confirmation-tokens.ts"() {
36849
+ "use strict";
36850
+ import_node_crypto4 = require("node:crypto");
36851
+ DEFAULT_SOFT_CONFIRM_TTL_MS = 5 * 60 * 1e3;
36852
+ TTL_MIN_MS = 6e4;
36853
+ TTL_MAX_MS = 9e5;
36854
+ MAX_OUTSTANDING_TOKENS = 50;
36855
+ MAX_MINTS_PER_15_MIN = 100;
36856
+ MINT_WINDOW_MS = 15 * 60 * 1e3;
36857
+ MIN_VALIDATE_FLOOR_MS = 5;
36858
+ SOFT_CONFIRM_RATE_LIMITED = "SOFT_CONFIRM_RATE_LIMITED";
36859
+ SoftConfirmRateLimitedError = class extends Error {
36860
+ code = SOFT_CONFIRM_RATE_LIMITED;
36861
+ current;
36862
+ limit;
36863
+ waitMs;
36864
+ constructor(current, limit, waitMs) {
36865
+ super(
36866
+ `Soft-confirmation mint cap exhausted: ${current} mints in the last 15 min, limit ${limit}. Window opens again in ~${Math.ceil(waitMs / 6e4)} min. Override via EPIMETHIAN_SOFT_CONFIRM_MINT_LIMIT (set "0" to disable).`
36867
+ );
36868
+ this.name = "SoftConfirmRateLimitedError";
36869
+ this.current = current;
36870
+ this.limit = limit;
36871
+ this.waitMs = waitMs;
36872
+ }
36873
+ };
36874
+ store = /* @__PURE__ */ new Map();
36875
+ mintTimestamps = [];
36876
+ insertSeqCounter = 0;
36877
+ mintHandlers = [];
36878
+ validateHandlers = [];
36879
+ }
36880
+ });
36881
+
36648
36882
  // node_modules/mdurl/lib/decode.mjs
36649
36883
  function getDecodeCache(exclude) {
36650
36884
  let cache = decodeCache[exclude];
@@ -46232,25 +46466,6 @@ var init_account_id_validator = __esm({
46232
46466
  }
46233
46467
  });
46234
46468
 
46235
- // src/server/converter/types.ts
46236
- var ConverterError, SHRINKAGE_NOT_CONFIRMED, STRUCTURE_LOSS_NOT_CONFIRMED, EMPTY_BODY_REJECTED, CONTENT_FLOOR_BREACHED;
46237
- var init_types2 = __esm({
46238
- "src/server/converter/types.ts"() {
46239
- "use strict";
46240
- ConverterError = class extends Error {
46241
- constructor(message, code2) {
46242
- super(message);
46243
- this.code = code2;
46244
- this.name = "ConverterError";
46245
- }
46246
- };
46247
- SHRINKAGE_NOT_CONFIRMED = "SHRINKAGE_NOT_CONFIRMED";
46248
- STRUCTURE_LOSS_NOT_CONFIRMED = "STRUCTURE_LOSS_NOT_CONFIRMED";
46249
- EMPTY_BODY_REJECTED = "EMPTY_BODY_REJECTED";
46250
- CONTENT_FLOOR_BREACHED = "CONTENT_FLOOR_BREACHED";
46251
- }
46252
- });
46253
-
46254
46469
  // src/server/converter/md-to-storage.ts
46255
46470
  function createHeadingSlugger() {
46256
46471
  const seen = /* @__PURE__ */ new Map();
@@ -47649,6 +47864,53 @@ function suppressEquivalentDeletionsEnabled() {
47649
47864
  const v = process.env.EPIMETHIAN_SUPPRESS_EQUIVALENT_DELETIONS;
47650
47865
  return v === "true" || v === "1";
47651
47866
  }
47867
+ async function maybeConsumeConfirmToken(args) {
47868
+ const { confirm_token, tool, cloudId, pageId, pageVersion, diffHash } = args;
47869
+ if (confirm_token === void 0 || cloudId === void 0 || pageVersion <= 0 || diffHash === void 0) {
47870
+ return "no_token";
47871
+ }
47872
+ const outcome = await validateToken(confirm_token, {
47873
+ tool,
47874
+ cloudId,
47875
+ pageId,
47876
+ pageVersion,
47877
+ diffHash
47878
+ });
47879
+ return outcome;
47880
+ }
47881
+ function formatSoftConfirmationResult(err, params) {
47882
+ const last8 = err.token.slice(-8);
47883
+ const isoExpires = new Date(err.expiresAt).toISOString();
47884
+ const text2 = `\u26A0\uFE0F Confirmation required (SOFT_CONFIRMATION_REQUIRED)
47885
+
47886
+ ${err.humanSummary}
47887
+
47888
+ Your MCP client does not support in-protocol elicitation. This
47889
+ confirmation is being routed through you (the agent). Please ASK
47890
+ THE USER before retrying. If the user approves, re-call this tool
47891
+ with the same parameters plus the \`confirm_token\` from
47892
+ structuredContent.
47893
+
47894
+ Token tail: ...${last8} Expires: ${isoExpires} Audit ID: ${err.auditId}
47895
+
47896
+ The token is single-use, bound to this exact diff and page version,
47897
+ and invalidated by any competing write to this page. If validation
47898
+ fails, mint a new one by re-calling without \`confirm_token\`.`;
47899
+ const structuredContent = {
47900
+ confirm_token: err.token,
47901
+ audit_id: err.auditId,
47902
+ expires_at: isoExpires,
47903
+ page_id: params.pageId
47904
+ };
47905
+ if (params.deletionSummary) {
47906
+ structuredContent.deletion_summary = params.deletionSummary;
47907
+ }
47908
+ return {
47909
+ content: [{ type: "text", text: text2 }],
47910
+ isError: true,
47911
+ structuredContent
47912
+ };
47913
+ }
47652
47914
  function detectMixedInput(body) {
47653
47915
  let stripped = body.replace(
47654
47916
  /^(`{3,})[^\n]*\n[\s\S]*?^\1\s*$/gm,
@@ -47988,7 +48250,8 @@ async function safeSubmitPage(input) {
47988
48250
  confirmStructureLoss,
47989
48251
  confirmDeletions,
47990
48252
  source,
47991
- assertGrowth
48253
+ assertGrowth,
48254
+ cloudId
47992
48255
  } = input;
47993
48256
  const isCreate = pageId === void 0;
47994
48257
  const resolvedOperation = operation ?? (isCreate ? "create_page" : "update_page");
@@ -48125,6 +48388,9 @@ async function safeSubmitPage(input) {
48125
48388
  });
48126
48389
  } catch {
48127
48390
  }
48391
+ if (cloudId !== void 0 && pageId !== void 0) {
48392
+ invalidateForPage(cloudId, pageId);
48393
+ }
48128
48394
  return {
48129
48395
  page,
48130
48396
  newVersion,
@@ -48355,6 +48621,7 @@ var init_safe_write = __esm({
48355
48621
  "src/server/safe-write.ts"() {
48356
48622
  "use strict";
48357
48623
  init_confluence_client();
48624
+ init_confirmation_tokens();
48358
48625
  init_md_to_storage();
48359
48626
  init_update_orchestrator();
48360
48627
  init_content_safety_guards();
@@ -48482,7 +48749,7 @@ async function writeCheckState(state) {
48482
48749
  const data = JSON.stringify(state, null, 2) + "\n";
48483
48750
  const tmpFile = (0, import_node_path3.join)(
48484
48751
  CONFIG_DIR2,
48485
- `.update-check.${(0, import_node_crypto4.randomBytes)(4).toString("hex")}.tmp`
48752
+ `.update-check.${(0, import_node_crypto5.randomBytes)(4).toString("hex")}.tmp`
48486
48753
  );
48487
48754
  await (0, import_promises3.writeFile)(tmpFile, data, { mode: 384 });
48488
48755
  await (0, import_promises3.rename)(tmpFile, UPDATE_CHECK_FILE);
@@ -48623,14 +48890,14 @@ async function checkForUpdates(currentVersion) {
48623
48890
  return null;
48624
48891
  }
48625
48892
  }
48626
- var import_promises3, import_node_path3, import_node_os2, import_node_crypto4, import_node_child_process2, import_node_util, execFileAsync, CONFIG_DIR2, UPDATE_CHECK_FILE, ONE_DAY_MS, NPM_REGISTRY_URL, PACKAGE_NAME;
48893
+ 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;
48627
48894
  var init_update_check = __esm({
48628
48895
  "src/shared/update-check.ts"() {
48629
48896
  "use strict";
48630
48897
  import_promises3 = require("node:fs/promises");
48631
48898
  import_node_path3 = require("node:path");
48632
48899
  import_node_os2 = require("node:os");
48633
- import_node_crypto4 = require("node:crypto");
48900
+ import_node_crypto5 = require("node:crypto");
48634
48901
  import_node_child_process2 = require("node:child_process");
48635
48902
  import_node_util = require("node:util");
48636
48903
  init_safe_fs();
@@ -48643,11 +48910,182 @@ var init_update_check = __esm({
48643
48910
  }
48644
48911
  });
48645
48912
 
48913
+ // src/cli/client-configs.ts
48914
+ var client_configs_exports = {};
48915
+ __export(client_configs_exports, {
48916
+ CLIENT_CONFIGS: () => CLIENT_CONFIGS,
48917
+ knownClientIds: () => knownClientIds,
48918
+ renderConfigSnippet: () => renderConfigSnippet
48919
+ });
48920
+ function renderConfigSnippet(clientId, profile, binPath) {
48921
+ const entry = CLIENT_CONFIGS.find((c) => c.id === clientId);
48922
+ if (!entry) {
48923
+ const valid = knownClientIds().join(", ");
48924
+ throw new Error(
48925
+ `Unknown client ID "${clientId}". Valid IDs are: ${valid}`
48926
+ );
48927
+ }
48928
+ const snippet = entry.template.replace(/\{\{PROFILE\}\}/g, profile).replace(/\{\{BIN\}\}/g, binPath);
48929
+ return { snippet, ...entry.warning ? { warning: entry.warning } : {} };
48930
+ }
48931
+ function knownClientIds() {
48932
+ return CLIENT_CONFIGS.map((c) => c.id);
48933
+ }
48934
+ var CLIENT_CONFIGS;
48935
+ var init_client_configs = __esm({
48936
+ "src/cli/client-configs.ts"() {
48937
+ "use strict";
48938
+ CLIENT_CONFIGS = [
48939
+ {
48940
+ id: "claude-code",
48941
+ displayName: "Claude Code",
48942
+ configFileHint: ".mcp.json",
48943
+ template: JSON.stringify(
48944
+ {
48945
+ mcpServers: {
48946
+ "epimethian-mcp": {
48947
+ command: "{{BIN}}",
48948
+ args: ["--profile", "{{PROFILE}}"]
48949
+ }
48950
+ }
48951
+ },
48952
+ null,
48953
+ 2
48954
+ )
48955
+ },
48956
+ {
48957
+ id: "claude-desktop",
48958
+ displayName: "Claude Desktop",
48959
+ configFileHint: "~/Library/Application Support/Claude/claude_desktop_config.json (macOS) / %APPDATA%\\Claude\\claude_desktop_config.json (Windows) / ~/.config/Claude/claude_desktop_config.json (Linux)",
48960
+ template: JSON.stringify(
48961
+ {
48962
+ mcpServers: {
48963
+ "epimethian-mcp": {
48964
+ command: "{{BIN}}",
48965
+ args: ["--profile", "{{PROFILE}}"]
48966
+ }
48967
+ }
48968
+ },
48969
+ null,
48970
+ 2
48971
+ )
48972
+ },
48973
+ {
48974
+ id: "claude-code-vscode",
48975
+ displayName: "Claude Code (VS Code extension)",
48976
+ configFileHint: "VS Code settings.json (mcp.servers block)",
48977
+ template: JSON.stringify(
48978
+ {
48979
+ "mcp.servers": {
48980
+ "epimethian-mcp": {
48981
+ command: "{{BIN}}",
48982
+ args: ["--profile", "{{PROFILE}}"]
48983
+ }
48984
+ }
48985
+ },
48986
+ null,
48987
+ 2
48988
+ ),
48989
+ 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`."
48990
+ },
48991
+ {
48992
+ id: "cursor",
48993
+ displayName: "Cursor",
48994
+ configFileHint: ".cursor/mcp.json",
48995
+ template: JSON.stringify(
48996
+ {
48997
+ mcpServers: {
48998
+ "epimethian-mcp": {
48999
+ command: "{{BIN}}",
49000
+ args: ["--profile", "{{PROFILE}}"]
49001
+ }
49002
+ }
49003
+ },
49004
+ null,
49005
+ 2
49006
+ )
49007
+ },
49008
+ {
49009
+ id: "windsurf",
49010
+ displayName: "Windsurf",
49011
+ configFileHint: "~/.codeium/windsurf/mcp_config.json",
49012
+ template: JSON.stringify(
49013
+ {
49014
+ mcpServers: {
49015
+ "epimethian-mcp": {
49016
+ command: "{{BIN}}",
49017
+ args: ["--profile", "{{PROFILE}}"]
49018
+ }
49019
+ }
49020
+ },
49021
+ null,
49022
+ 2
49023
+ )
49024
+ },
49025
+ {
49026
+ id: "zed",
49027
+ displayName: "Zed",
49028
+ configFileHint: "~/.config/zed/settings.json (context_servers block)",
49029
+ template: JSON.stringify(
49030
+ {
49031
+ context_servers: {
49032
+ "epimethian-mcp": {
49033
+ command: {
49034
+ path: "{{BIN}}",
49035
+ args: ["--profile", "{{PROFILE}}"]
49036
+ }
49037
+ }
49038
+ }
49039
+ },
49040
+ null,
49041
+ 2
49042
+ )
49043
+ },
49044
+ {
49045
+ id: "opencode",
49046
+ displayName: "OpenCode",
49047
+ configFileHint: "opencode.json or ~/.config/opencode/opencode.json",
49048
+ template: JSON.stringify(
49049
+ {
49050
+ mcp: {
49051
+ "epimethian-mcp": {
49052
+ type: "local",
49053
+ command: ["{{BIN}}", "--profile", "{{PROFILE}}"],
49054
+ environment: {
49055
+ EPIMETHIAN_ALLOW_UNGATED_WRITES: "true"
49056
+ }
49057
+ }
49058
+ }
49059
+ },
49060
+ null,
49061
+ 2
49062
+ ),
49063
+ warning: "OpenCode does not yet support MCP elicitation. The `EPIMETHIAN_ALLOW_UNGATED_WRITES=true` env var above removes the interactive confirmation prompt for destructive operations. Read tools and additive writes work without any flag. Upgrade to epimethian-mcp v6.6.0 to get soft elicitation (confirmations routed through the agent), and remove the env var when you do."
49064
+ }
49065
+ ];
49066
+ }
49067
+ });
49068
+
48646
49069
  // src/cli/setup.ts
48647
49070
  var setup_exports = {};
48648
49071
  __export(setup_exports, {
48649
49072
  runSetup: () => runSetup
48650
49073
  });
49074
+ function resolveBinPath() {
49075
+ const argv1 = process.argv[1];
49076
+ if (argv1 && argv1.startsWith("/")) {
49077
+ return argv1;
49078
+ }
49079
+ try {
49080
+ const result = (0, import_node_child_process3.execSync)("which epimethian-mcp", { encoding: "utf8" }).trim();
49081
+ if (result) return result;
49082
+ } catch {
49083
+ }
49084
+ process.stderr.write(
49085
+ "Warning: could not determine absolute path to epimethian-mcp. Replace <absolute path to epimethian-mcp> in the snippet below with the correct path.\n"
49086
+ );
49087
+ return "<absolute path to epimethian-mcp>";
49088
+ }
48651
49089
  function readPassword(prompt) {
48652
49090
  import_node_process2.stdout.write(prompt);
48653
49091
  return new Promise((resolve2) => {
@@ -48678,13 +49116,22 @@ function readPassword(prompt) {
48678
49116
  import_node_process2.stdin.on("data", onData);
48679
49117
  });
48680
49118
  }
48681
- async function runSetup(profile) {
49119
+ async function runSetup(profile, clientId) {
48682
49120
  if (!import_node_process2.stdin.isTTY) {
48683
49121
  console.error(
48684
49122
  "Error: setup requires an interactive terminal.\nFor non-interactive environments, set CONFLUENCE_URL, CONFLUENCE_EMAIL, and CONFLUENCE_API_TOKEN as environment variables."
48685
49123
  );
48686
49124
  process.exit(1);
48687
49125
  }
49126
+ if (clientId !== void 0) {
49127
+ const validIds = knownClientIds();
49128
+ if (!validIds.includes(clientId)) {
49129
+ console.error(
49130
+ `Error: Unknown --client "${clientId}". Valid IDs are: ${validIds.join(", ")}`
49131
+ );
49132
+ process.exit(1);
49133
+ }
49134
+ }
48688
49135
  if (profile !== void 0 && !PROFILE_NAME_RE.test(profile)) {
48689
49136
  console.error(
48690
49137
  `Error: Invalid profile name "${profile}". Use lowercase alphanumeric and hyphens only (1-63 chars).`
@@ -48843,19 +49290,37 @@ Your choice [default: 1]: `
48843
49290
  console.log(
48844
49291
  "\nSetup complete. Restart your MCP client to use the new credentials."
48845
49292
  );
49293
+ const binPath = resolveBinPath();
49294
+ const effectiveProfile = profile ?? "default";
49295
+ const clientsToShow = clientId ? [clientId] : knownClientIds();
49296
+ for (const id of clientsToShow) {
49297
+ const entry = (await Promise.resolve().then(() => (init_client_configs(), client_configs_exports))).CLIENT_CONFIGS.find(
49298
+ (c) => c.id === id
49299
+ );
49300
+ const { snippet, warning } = renderConfigSnippet(id, effectiveProfile, binPath);
49301
+ console.log(`
49302
+ --- ${entry.displayName} config (${entry.configFileHint}) ---`);
49303
+ console.log(snippet);
49304
+ if (warning) {
49305
+ console.log(`
49306
+ Note: ${warning}`);
49307
+ }
49308
+ }
48846
49309
  } finally {
48847
49310
  rl.close();
48848
49311
  }
48849
49312
  }
48850
- var readline, import_node_process2, TOOLS;
49313
+ var readline, import_node_process2, import_node_child_process3, TOOLS;
48851
49314
  var init_setup = __esm({
48852
49315
  "src/cli/setup.ts"() {
48853
49316
  "use strict";
48854
49317
  readline = __toESM(require("node:readline/promises"));
48855
49318
  import_node_process2 = require("node:process");
49319
+ import_node_child_process3 = require("node:child_process");
48856
49320
  init_test_connection();
48857
49321
  init_keychain();
48858
49322
  init_profiles();
49323
+ init_client_configs();
48859
49324
  TOOLS = [
48860
49325
  "create_page",
48861
49326
  "get_page",
@@ -49447,7 +49912,7 @@ Informational:
49447
49912
  var install_agent_default;
49448
49913
  var init_install_agent = __esm({
49449
49914
  "install-agent.md"() {
49450
- 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\nAdd the server to `.mcp.json` (or the equivalent config file for the user\'s MCP client):\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**IMPORTANT:** The only env var needed is `CONFLUENCE_PROFILE`. The URL, email, and API token are stored securely in the OS keychain \u2014 they should NOT 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## 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';
49915
+ 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 elicitation)\n\nSome MCP clients (currently OpenCode, plus others) don\'t implement the in-protocol\nconfirmation prompt. Starting in v6.6.0, epimethian-mcp routes those confirmations\nthrough your agent\'s normal chat surface instead.\n\n### What you (the agent) see\n\nWhen a destructive write is requested against a client without elicitation, the\ntool returns an error with a confirmation token:\n\n```\nisError: true\nstructuredContent:\n {\n "confirm_token": "<opaque token>",\n "audit_id": "<UUID for correlation>",\n "expires_at": "<ISO timestamp>",\n "page_id": "<pageId>",\n ...\n }\ncontent[0].text:\n \u26A0\uFE0F Confirmation required (SOFT_CONFIRMATION_REQUIRED)\n\n {humanSummary}\n\n Please ask the user before retrying. If approved, re-call with:\n "confirm_token": from structuredContent.\n\n Expires at {timestamp}; invalidated by competing writes.\n```\n\n### What to do\n\n1. STOP. Don\'t retry blindly.\n2. Show the user, in their language, what\'s about to happen (use the\n `humanSummary` field from the result).\n3. Ask the user explicitly. Wait for their answer.\n4. If approved: re-call the tool with the SAME parameters plus\n `confirm_token` from the structuredContent.\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).\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### 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 |\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 \u2264 2.1.123** | Fakes it | Set `EPIMETHIAN_BYPASS_ELICITATION=true` (see below). |\n| **Claude Code VS Code extension \u2265 2.1.124** | Likely fixed (verify) | If write tools fail with `NO_USER_RESPONSE`, fall back to `EPIMETHIAN_BYPASS_ELICITATION=true`. |\n| **OpenCode** | No \u2014 capability not advertised | Set `EPIMETHIAN_ALLOW_UNGATED_WRITES=true` or use only read tools / additive writes that don\'t trigger the gate. No tracking issue at sst/opencode yet (as of v6.4.1); a feature request would be needed for real elicitation support. |\n| **Cursor / Windsurf / Zed / others** | Varies | If write tools fail with `ELICITATION_REQUIRED_BUT_UNAVAILABLE`, the client doesn\'t advertise the capability \u2014 use `EPIMETHIAN_ALLOW_UNGATED_WRITES=true`. If write tools fail with `NO_USER_RESPONSE` despite the client claiming support, the client fakes it \u2014 use `EPIMETHIAN_BYPASS_ELICITATION=true`. |\n\n### Difference between the two bypass env vars\n\nThese are **not** interchangeable. Pick the one that matches the failure mode:\n\n- **`EPIMETHIAN_ALLOW_UNGATED_WRITES=true`** \u2014 for clients that *don\'t\n advertise* elicitation during the MCP handshake. The server detects the\n absence and (with this flag) lets writes proceed. OpenCode falls in this\n category.\n- **`EPIMETHIAN_BYPASS_ELICITATION=true`** \u2014 for clients that *advertise*\n elicitation but never actually honour the request (the SDK transport\n silently returns `{action: "decline"}`). The Claude Code VS Code\n extension \u2264 2.1.123 falls in this category. This flag is unconditional \u2014\n it bypasses elicitation even when the client claims to support it.\n\n### Trade-off: what you give up by setting either flag\n\nBoth flags **disable the in-protocol confirmation gate**. Writes still go\nthrough the harness\'s tool allow-list (so users can still block the tool\nin their permission settings) and through every server-side guard\n(provenance, source-policy, write-budget, byte-equivalence) \u2014 but the user\nno longer gets a UI prompt before each destructive operation. Recommend\nthis 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## 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';
49451
49916
  }
49452
49917
  });
49453
49918
 
@@ -49472,7 +49937,7 @@ __export(upgrade_exports, {
49472
49937
  runUpgrade: () => runUpgrade
49473
49938
  });
49474
49939
  async function runUpgrade() {
49475
- const currentVersion = "6.4.1";
49940
+ const currentVersion = "6.6.0";
49476
49941
  console.log(`epimethian-mcp upgrade: current version v${currentVersion}`);
49477
49942
  let pending = await getPendingUpdate();
49478
49943
  if (!pending) {
@@ -60255,6 +60720,7 @@ function computeUnifiedDiff(textA, textB, maxLength) {
60255
60720
  }
60256
60721
 
60257
60722
  // src/server/index.ts
60723
+ init_types2();
60258
60724
  init_untrusted_fence();
60259
60725
 
60260
60726
  // src/server/converter/storage-to-md.ts
@@ -60407,10 +60873,12 @@ function listDestructiveFlagsSet(flags) {
60407
60873
  init_write_budget();
60408
60874
 
60409
60875
  // src/server/elicitation.ts
60876
+ init_confirmation_tokens();
60410
60877
  var USER_DECLINED = "USER_DECLINED";
60411
60878
  var USER_CANCELLED = "USER_CANCELLED";
60412
60879
  var NO_USER_RESPONSE = "NO_USER_RESPONSE";
60413
60880
  var ELICITATION_REQUIRED_BUT_UNAVAILABLE = "ELICITATION_REQUIRED_BUT_UNAVAILABLE";
60881
+ var SOFT_CONFIRMATION_REQUIRED = "SOFT_CONFIRMATION_REQUIRED";
60414
60882
  var GatedOperationError = class extends Error {
60415
60883
  code;
60416
60884
  constructor(code2, message) {
@@ -60419,21 +60887,103 @@ var GatedOperationError = class extends Error {
60419
60887
  this.code = code2;
60420
60888
  }
60421
60889
  };
60890
+ var SoftConfirmationRequiredError = class extends GatedOperationError {
60891
+ token;
60892
+ auditId;
60893
+ expiresAt;
60894
+ humanSummary;
60895
+ retryHint;
60896
+ pageId;
60897
+ constructor(args) {
60898
+ super(SOFT_CONFIRMATION_REQUIRED, args.message);
60899
+ this.name = "SoftConfirmationRequiredError";
60900
+ this.token = args.token;
60901
+ this.auditId = args.auditId;
60902
+ this.expiresAt = args.expiresAt;
60903
+ this.humanSummary = args.humanSummary;
60904
+ this.retryHint = args.retryHint;
60905
+ this.pageId = args.pageId;
60906
+ }
60907
+ };
60908
+ function renderDeletionSummary(s) {
60909
+ const parts = [];
60910
+ if (s.tocs > 0) parts.push(`${s.tocs} TOC macro${s.tocs === 1 ? "" : "s"}`);
60911
+ if (s.links > 0) parts.push(`${s.links} link macro${s.links === 1 ? "" : "s"}`);
60912
+ if (s.codeMacros > 0)
60913
+ parts.push(`${s.codeMacros} code macro${s.codeMacros === 1 ? "" : "s"}`);
60914
+ if (s.structuredMacros > 0)
60915
+ parts.push(
60916
+ `${s.structuredMacros} structured macro${s.structuredMacros === 1 ? "" : "s"}`
60917
+ );
60918
+ if (s.plainElements > 0)
60919
+ parts.push(
60920
+ `${s.plainElements} plain element${s.plainElements === 1 ? "" : "s"}`
60921
+ );
60922
+ if (s.other > 0)
60923
+ parts.push(`${s.other} other element${s.other === 1 ? "" : "s"}`);
60924
+ if (parts.length === 0) {
60925
+ return "This update has no destructive changes.";
60926
+ }
60927
+ const list2 = parts.length === 1 ? parts[0] : parts.slice(0, -1).join(", ") + " and " + parts[parts.length - 1];
60928
+ return `This update will remove ${list2}.`;
60929
+ }
60930
+ var bypassMisconfigWarningFired = false;
60422
60931
  async function gateOperation(server, context) {
60932
+ const supported = clientSupportsElicitation(server);
60423
60933
  if (process.env.EPIMETHIAN_BYPASS_ELICITATION === "true") {
60934
+ if (!supported && !bypassMisconfigWarningFired) {
60935
+ bypassMisconfigWarningFired = true;
60936
+ console.error(
60937
+ `epimethian-mcp: BYPASS_ELICITATION is set, but the connected client does not advertise elicitation support. The intended use of BYPASS_ELICITATION is for clients that falsely advertise the capability and never honour requests. For clients that don't advertise it (e.g. OpenCode), set EPIMETHIAN_ALLOW_UNGATED_WRITES instead, or upgrade to v6.6.0 to benefit from soft elicitation.`
60938
+ );
60939
+ }
60424
60940
  console.error(
60425
60941
  `epimethian-mcp: [UNGATED] tool=${context.tool} \u2014 bypassing elicitation gate; proceeding because EPIMETHIAN_BYPASS_ELICITATION=true.`
60426
60942
  );
60427
60943
  return;
60428
60944
  }
60429
- const supported = clientSupportsElicitation(server);
60945
+ if (!supported && process.env.EPIMETHIAN_ALLOW_UNGATED_WRITES === "true") {
60946
+ console.error(
60947
+ `epimethian-mcp: [UNGATED] tool=${context.tool} \u2014 client does not support elicitation; proceeding because EPIMETHIAN_ALLOW_UNGATED_WRITES=true.`
60948
+ );
60949
+ return;
60950
+ }
60951
+ if (!supported && process.env.EPIMETHIAN_DISABLE_SOFT_CONFIRM === "true") {
60952
+ throw new GatedOperationError(
60953
+ ELICITATION_REQUIRED_BUT_UNAVAILABLE,
60954
+ `This tool requires interactive confirmation but your MCP client does not expose elicitation, and EPIMETHIAN_DISABLE_SOFT_CONFIRM is set. Use \`update_page_section\` instead, or switch to a client that supports MCP elicitation (Claude Code \u2265 2.x, Claude Desktop \u2265 0.10).`
60955
+ );
60956
+ }
60957
+ if (!supported && context.cloudId !== void 0 && context.pageId !== void 0 && context.pageVersion !== void 0 && context.diffHash !== void 0) {
60958
+ const deletionSummary = context.details?.deletionSummary;
60959
+ let humanSummary;
60960
+ if (deletionSummary !== void 0 && typeof deletionSummary === "object" && deletionSummary !== null && !Array.isArray(deletionSummary)) {
60961
+ humanSummary = renderDeletionSummary(deletionSummary);
60962
+ } else {
60963
+ humanSummary = context.summary;
60964
+ }
60965
+ const minted = mintToken({
60966
+ tool: context.tool,
60967
+ cloudId: context.cloudId,
60968
+ pageId: context.pageId,
60969
+ pageVersion: context.pageVersion,
60970
+ diffHash: context.diffHash
60971
+ });
60972
+ const retryHint = `Re-call \`${context.tool}\` with the same parameters plus \`confirm_token\` set to the value in structuredContent.confirm_token.`;
60973
+ throw new SoftConfirmationRequiredError({
60974
+ token: minted.token,
60975
+ auditId: minted.auditId,
60976
+ expiresAt: minted.expiresAt,
60977
+ humanSummary,
60978
+ retryHint,
60979
+ pageId: context.pageId,
60980
+ // The message is consumed by the index.ts catch in §5.5; the
60981
+ // structured fields above are the load-bearing payload. Keep the
60982
+ // text agent-directed (not user-facing) and free of tenant content.
60983
+ message: `Soft confirmation required for ${context.tool}: surface the prompt to the user and retry with the confirm_token.`
60984
+ });
60985
+ }
60430
60986
  if (!supported) {
60431
- if (process.env.EPIMETHIAN_ALLOW_UNGATED_WRITES === "true") {
60432
- console.error(
60433
- `epimethian-mcp: [UNGATED] tool=${context.tool} \u2014 client does not support elicitation; proceeding because EPIMETHIAN_ALLOW_UNGATED_WRITES=true.`
60434
- );
60435
- return;
60436
- }
60437
60987
  throw new GatedOperationError(
60438
60988
  ELICITATION_REQUIRED_BUT_UNAVAILABLE,
60439
60989
  `This tool requires interactive confirmation but your MCP client does not expose elicitation. Use \`update_page_section\` instead, or switch to a client that supports MCP elicitation (Claude Code \u2265 2.x, Claude Desktop \u2265 0.10).`
@@ -60444,17 +60994,11 @@ async function gateOperation(server, context) {
60444
60994
  for (const [k, v] of Object.entries(context.details)) {
60445
60995
  if (v === void 0) continue;
60446
60996
  if (k === "deletionSummary" && typeof v === "object" && v !== null) {
60447
- const s = v;
60448
- const parts = [];
60449
- if (s.tocs > 0) parts.push(`${s.tocs} TOC macro${s.tocs === 1 ? "" : "s"}`);
60450
- if (s.links > 0) parts.push(`${s.links} link macro${s.links === 1 ? "" : "s"}`);
60451
- if (s.codeMacros > 0) parts.push(`${s.codeMacros} code macro${s.codeMacros === 1 ? "" : "s"}`);
60452
- if (s.structuredMacros > 0) parts.push(`${s.structuredMacros} structured macro${s.structuredMacros === 1 ? "" : "s"}`);
60453
- if (s.plainElements > 0) parts.push(`${s.plainElements} plain element${s.plainElements === 1 ? "" : "s"}`);
60454
- if (s.other > 0) parts.push(`${s.other} other element${s.other === 1 ? "" : "s"}`);
60455
- if (parts.length > 0) {
60456
- const list2 = parts.length === 1 ? parts[0] : parts.slice(0, -1).join(", ") + " and " + parts[parts.length - 1];
60457
- lines.push(` This update will remove ${list2} that the new markdown does not regenerate. Proceed?`);
60997
+ const rendered = renderDeletionSummary(v);
60998
+ if (rendered !== "This update has no destructive changes.") {
60999
+ lines.push(
61000
+ ` ${rendered.replace(/\.$/, "")} that the new markdown does not regenerate. Proceed?`
61001
+ );
60458
61002
  }
60459
61003
  continue;
60460
61004
  }
@@ -60506,6 +61050,7 @@ async function gateOperation(server, context) {
60506
61050
  }
60507
61051
 
60508
61052
  // src/server/index.ts
61053
+ init_confirmation_tokens();
60509
61054
  init_update_orchestrator();
60510
61055
  init_tokeniser();
60511
61056
 
@@ -61059,7 +61604,9 @@ async function registerTools(server, config3) {
61059
61604
  deletedTokens: prepared.deletedTokens,
61060
61605
  clientLabel: getClientLabel(server),
61061
61606
  operation: position === "prepend" ? "prepend_to_page" : "append_to_page",
61062
- assertGrowth: true
61607
+ assertGrowth: true,
61608
+ // 2.E: defense-in-depth invalidation.
61609
+ cloudId: opts.cloudId
61063
61610
  });
61064
61611
  return { page: submitted.page, newVersion: submitted.newVersion, oldLen: currentStorage.length, newLen: newBody.length };
61065
61612
  }
@@ -61244,7 +61791,7 @@ ${truncated}${truncationNote(origLen)}`
61244
61791
  {
61245
61792
  description: describeWithLock(
61246
61793
  withDestructiveWarning(
61247
- "Update an existing Confluence page. Accepts GFM markdown or Confluence storage format \u2014 markdown is automatically converted via the token-aware write path, which preserves all existing macros and rich elements. Do NOT mix the two: a body that contains both <ac:.../> storage tags AND markdown structural patterns (## headings, lists, fenced code blocks) is rejected with MIXED_INPUT_DETECTED. To inject a TOC macro from markdown, use YAML frontmatter at the top of the body: `---\\ntoc:\\n maxLevel: 3\\n minLevel: 1\\n---`. For other macros from markdown, use directive syntax: `:info[content]`, `:mention[Name]{accountId=...}`, `:date[2026-04-23]`. You must provide the version number from your most recent get_page call. If the page was modified by someone else since then, this will return a conflict error \u2014 re-read the page and retry.\n\nFor narrow changes to a single section, prefer update_page_section \u2014 it leaves the rest of the page untouched and is safer for targeted edits.\n\nMarkdown update flags:\n- confirm_deletions: set to true to acknowledge removing preserved macros/elements (default false \u2014 any deletion errors until confirmed).\n- replace_body: set to true for a wholesale rewrite that skips preservation (default false).\n- confirm_shrinkage: set to true to acknowledge a >50% body size reduction (default false).\n- confirm_structure_loss: set to true to acknowledge a >50% heading count drop (default false).\n- allow_raw_html: allow raw HTML inside markdown bodies (default false).\n- confluence_base_url: override the URL used by the link rewriter.\n\nreplace_body skips all safety nets (token preservation, deletion confirmation). When delegating update_page to a subagent, ensure the agent includes the full existing body \u2014 replace_body replaces ALL content with only what you provide."
61794
+ "Update an existing Confluence page. Accepts GFM markdown or Confluence storage format \u2014 markdown is automatically converted via the token-aware write path, which preserves all existing macros and rich elements. Do NOT mix the two: a body that contains both <ac:.../> storage tags AND markdown structural patterns (## headings, lists, fenced code blocks) is rejected with MIXED_INPUT_DETECTED. To inject a TOC macro from markdown, use YAML frontmatter at the top of the body: `---\\ntoc:\\n maxLevel: 3\\n minLevel: 1\\n---`. For other macros from markdown, use directive syntax: `:info[content]`, `:mention[Name]{accountId=...}`, `:date[2026-04-23]`. You must provide the version number from your most recent get_page call. If the page was modified by someone else since then, this will return a conflict error \u2014 re-read the page and retry.\n\nFor narrow changes to a single section, prefer update_page_section \u2014 it leaves the rest of the page untouched and is safer for targeted edits.\n\nMarkdown update flags:\n- confirm_deletions: set to true to acknowledge removing preserved macros/elements (default false \u2014 any deletion errors until confirmed).\n- replace_body: set to true for a wholesale rewrite that skips preservation (default false).\n- confirm_shrinkage: set to true to acknowledge a >50% body size reduction (default false).\n- confirm_structure_loss: set to true to acknowledge a >50% heading count drop (default false).\n- allow_raw_html: allow raw HTML inside markdown bodies (default false).\n- confluence_base_url: override the URL used by the link rewriter.\n\nreplace_body skips all safety nets (token preservation, deletion confirmation). When delegating update_page to a subagent, ensure the agent includes the full existing body \u2014 replace_body replaces ALL content with only what you provide.\n\nIf your MCP client does not support in-protocol confirmation, this tool returns `SOFT_CONFIRMATION_REQUIRED` on the first call when destructive flags are set. STOP and ask the user before retrying. If the user approves, re-call this tool with the same parameters plus `confirm_token` from the first response. The token expires after 5 minutes and is invalidated by competing writes. See the 'Soft confirmation' section of `install-agent.md` for the full protocol."
61248
61795
  ),
61249
61796
  config3
61250
61797
  ),
@@ -61266,11 +61813,12 @@ ${truncated}${truncationNote(origLen)}`
61266
61813
  ),
61267
61814
  allow_raw_html: external_exports.boolean().default(false).describe("Allow raw HTML passthrough inside markdown bodies (disabled by default)."),
61268
61815
  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."),
61269
- source: sourceSchema
61816
+ source: sourceSchema,
61817
+ 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.")
61270
61818
  },
61271
61819
  annotations: { destructiveHint: false, idempotentHint: false }
61272
61820
  },
61273
- 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 }) => {
61821
+ 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 }) => {
61274
61822
  const blocked = writeGuard("update_page", config3);
61275
61823
  if (blocked) return blocked;
61276
61824
  try {
@@ -61283,21 +61831,43 @@ ${truncated}${truncationNote(origLen)}`
61283
61831
  });
61284
61832
  const effectiveSource = validateSource(source, flagsSet);
61285
61833
  const cfg = await getConfig();
61834
+ const cloudId = cfg.sealedCloudId;
61286
61835
  const currentPage = await getPage(page_id, true);
61287
61836
  const currentStorage = currentPage.body?.storage?.value ?? currentPage.body?.value ?? "";
61837
+ const pageVersion = currentPage.version?.number ?? 0;
61288
61838
  if (flagsSet.length > 0) {
61289
61839
  const deletionSummary = confirm_deletions && body ? tryForecastDeletions(currentStorage, body, confluence_base_url ?? cfg.url) : null;
61290
- await gateOperation(server, {
61840
+ const diffHash = cloudId && pageVersion > 0 ? computeDiffHash(body ?? currentStorage, pageVersion) : void 0;
61841
+ const tokenResult = await maybeConsumeConfirmToken({
61842
+ confirm_token,
61291
61843
  tool: "update_page",
61292
- summary: `Update page ${page_id} with destructive flags?`,
61293
- details: {
61294
- page_id,
61295
- flags: flagsSet.join(","),
61296
- source: effectiveSource,
61297
- version: version2,
61298
- ...deletionSummary ? { deletionSummary } : {}
61299
- }
61844
+ cloudId,
61845
+ pageId: page_id,
61846
+ pageVersion,
61847
+ diffHash
61300
61848
  });
61849
+ if (tokenResult === "invalid") {
61850
+ throw new ConverterError(
61851
+ "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.",
61852
+ "CONFIRMATION_TOKEN_INVALID"
61853
+ );
61854
+ } else if (tokenResult === "no_token") {
61855
+ await gateOperation(server, {
61856
+ tool: "update_page",
61857
+ summary: `Update page ${page_id} with destructive flags?`,
61858
+ details: {
61859
+ page_id,
61860
+ flags: flagsSet.join(","),
61861
+ source: effectiveSource,
61862
+ version: version2,
61863
+ ...deletionSummary ? { deletionSummary } : {}
61864
+ },
61865
+ cloudId,
61866
+ pageId: page_id,
61867
+ pageVersion,
61868
+ diffHash
61869
+ });
61870
+ }
61301
61871
  }
61302
61872
  const resolvedVersion = version2 === "current" ? currentPage.version?.number ?? 0 : version2;
61303
61873
  if (resolvedVersion <= 0) {
@@ -61331,7 +61901,9 @@ ${truncated}${truncationNote(origLen)}`
61331
61901
  confirmStructureLoss: confirm_structure_loss,
61332
61902
  confirmDeletions: confirm_deletions,
61333
61903
  // E2: thread the validated source into the mutation log.
61334
- source: effectiveSource
61904
+ source: effectiveSource,
61905
+ // 2.E: defense-in-depth invalidation.
61906
+ cloudId
61335
61907
  });
61336
61908
  const isTitleOnly = prepared.finalStorage === void 0;
61337
61909
  const warnings = [];
@@ -61349,6 +61921,9 @@ ${truncated}${truncationNote(origLen)}`
61349
61921
  appendWarnings(`Updated: ${submitted.page.title} (ID: ${submitted.page.id}, version: ${submitted.newVersion}, body: ${submitted.oldLen}\u2192${submitted.newLen} chars${removalNote})`, warnings) + echo
61350
61922
  );
61351
61923
  } catch (err) {
61924
+ if (err instanceof SoftConfirmationRequiredError) {
61925
+ return formatSoftConfirmationResult(err, { pageId: page_id });
61926
+ }
61352
61927
  return toolErrorWithContext(err, { operation: "update_page", resource: `page ${page_id}`, profile: config3.profile });
61353
61928
  }
61354
61929
  }
@@ -61358,7 +61933,7 @@ ${truncated}${truncationNote(origLen)}`
61358
61933
  {
61359
61934
  description: describeWithLock(
61360
61935
  withDestructiveWarning(
61361
- "Delete a Confluence page by ID. Requires the current `version` from your most recent get_page call \u2014 delete is refused if the page has been modified since. Set EPIMETHIAN_LEGACY_DELETE_WITHOUT_VERSION=true to restore the previous version-less behaviour for one release while scripts are migrated."
61936
+ "Delete a Confluence page by ID. Requires the current `version` from your most recent get_page call \u2014 delete is refused if the page has been modified since. Set EPIMETHIAN_LEGACY_DELETE_WITHOUT_VERSION=true to restore the previous version-less behaviour for one release while scripts are migrated.\n\nIf your MCP client does not support in-protocol confirmation, this tool returns `SOFT_CONFIRMATION_REQUIRED` on the first call when destructive flags are set. STOP and ask the user before retrying. If the user approves, re-call this tool with the same parameters plus `confirm_token` from the first response. The token expires after 5 minutes and is invalidated by competing writes. See the 'Soft confirmation' section of `install-agent.md` for the full protocol."
61362
61937
  ),
61363
61938
  config3
61364
61939
  ),
@@ -61367,11 +61942,12 @@ ${truncated}${truncationNote(origLen)}`
61367
61942
  version: external_exports.number().int().positive().optional().describe(
61368
61943
  "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."
61369
61944
  ),
61370
- source: sourceSchema
61945
+ source: sourceSchema,
61946
+ 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.")
61371
61947
  },
61372
61948
  annotations: { destructiveHint: true, idempotentHint: true }
61373
61949
  },
61374
- async ({ page_id, version: version2, source }) => {
61950
+ async ({ page_id, version: version2, source, confirm_token }) => {
61375
61951
  const blocked = writeGuard("delete_page", config3);
61376
61952
  if (blocked) return blocked;
61377
61953
  try {
@@ -61390,17 +61966,43 @@ ${truncated}${truncationNote(origLen)}`
61390
61966
  `epimethian-mcp: WARNING: delete_page on page ${page_id} without a version (legacy opt-out active). This opt-out will be removed in a future release.`
61391
61967
  );
61392
61968
  }
61393
- await gateOperation(server, {
61969
+ const cfg = await getConfig();
61970
+ const cloudId = cfg.sealedCloudId;
61971
+ const pageVersion = version2 ?? 0;
61972
+ const diffHash = cloudId && pageVersion > 0 ? computeDiffHash("", pageVersion) : void 0;
61973
+ const tokenResult = await maybeConsumeConfirmToken({
61974
+ confirm_token,
61394
61975
  tool: "delete_page",
61395
- summary: `Delete page ${page_id}?`,
61396
- details: {
61397
- page_id,
61398
- version: version2 ?? "(legacy: unversioned)",
61399
- source: effectiveSource
61400
- }
61976
+ cloudId,
61977
+ pageId: page_id,
61978
+ pageVersion,
61979
+ diffHash
61401
61980
  });
61981
+ if (tokenResult === "invalid") {
61982
+ throw new ConverterError(
61983
+ "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.",
61984
+ "CONFIRMATION_TOKEN_INVALID"
61985
+ );
61986
+ } else if (tokenResult === "no_token") {
61987
+ await gateOperation(server, {
61988
+ tool: "delete_page",
61989
+ summary: `Delete page ${page_id}?`,
61990
+ details: {
61991
+ page_id,
61992
+ version: version2 ?? "(legacy: unversioned)",
61993
+ source: effectiveSource
61994
+ },
61995
+ cloudId,
61996
+ pageId: page_id,
61997
+ pageVersion,
61998
+ diffHash
61999
+ });
62000
+ }
61402
62001
  writeBudget.consume();
61403
62002
  await deletePage(page_id, version2);
62003
+ if (cloudId !== void 0) {
62004
+ invalidateForPage(cloudId, page_id);
62005
+ }
61404
62006
  logMutation({
61405
62007
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
61406
62008
  operation: "delete_page",
@@ -61410,6 +62012,9 @@ ${truncated}${truncationNote(origLen)}`
61410
62012
  });
61411
62013
  return toolResult(`Deleted page ${page_id}` + echo);
61412
62014
  } catch (err) {
62015
+ if (err instanceof SoftConfirmationRequiredError) {
62016
+ return formatSoftConfirmationResult(err, { pageId: page_id });
62017
+ }
61413
62018
  logMutation(errorRecord("delete_page", page_id, err));
61414
62019
  return toolErrorWithContext(err, { operation: "delete_page", resource: `page ${page_id}`, profile: config3.profile });
61415
62020
  }
@@ -61420,7 +62025,7 @@ ${truncated}${truncationNote(origLen)}`
61420
62025
  {
61421
62026
  description: describeWithLock(
61422
62027
  withDestructiveWarning(
61423
- "Update a single section of a Confluence page by heading name. Only the content under the specified heading is replaced; the rest of the page is untouched. Use headings_only to find section names first. Note: in Confluence spaces with heading auto-numbering enabled, stored heading text contains the prefix (e.g. `1.2. Section`); the matcher accepts either the prefixed or plain form."
62028
+ "Update a single section of a Confluence page by heading name. Only the content under the specified heading is replaced; the rest of the page is untouched. Use headings_only to find section names first. Note: in Confluence spaces with heading auto-numbering enabled, stored heading text contains the prefix (e.g. `1.2. Section`); the matcher accepts either the prefixed or plain form.\n\nIf your MCP client does not support in-protocol confirmation, this tool returns `SOFT_CONFIRMATION_REQUIRED` on the first call when destructive flags are set. STOP and ask the user before retrying. If the user approves, re-call this tool with the same parameters plus `confirm_token` from the first response. The token expires after 5 minutes and is invalidated by competing writes. See the 'Soft confirmation' section of `install-agent.md` for the full protocol."
61424
62029
  ),
61425
62030
  config3
61426
62031
  ),
@@ -61446,11 +62051,12 @@ ${truncated}${truncationNote(origLen)}`
61446
62051
  `The page version number from your most recent get_page call. Pass the literal string "current" to skip the read and apply this update on top of whatever the latest version is right now. WARNING: "current" deliberately bypasses optimistic concurrency \u2014 it is NOT a conflict-resolution strategy. If a coworker (or another agent) writes between our read and submit, the API will still 409. Use a numeric version when you want the "don't overwrite my coworker's changes" guard.`
61447
62052
  ),
61448
62053
  version_message: external_exports.string().optional().describe("Optional version comment"),
61449
- 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.")
62054
+ 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."),
62055
+ 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.")
61450
62056
  },
61451
62057
  annotations: { destructiveHint: false, idempotentHint: false }
61452
62058
  },
61453
- async ({ page_id, section, body, find_replace, version: version2, version_message, confirm_deletions }) => {
62059
+ async ({ page_id, section, body, find_replace, version: version2, version_message, confirm_deletions, confirm_token }) => {
61454
62060
  const blocked = writeGuard("update_page_section", config3);
61455
62061
  if (blocked) return blocked;
61456
62062
  try {
@@ -61472,8 +62078,10 @@ ${truncated}${truncationNote(origLen)}`
61472
62078
  }
61473
62079
  await checkSpaceAllowed({ pageId: page_id });
61474
62080
  const cfg = await getConfig();
62081
+ const cloudId = cfg.sealedCloudId;
61475
62082
  const page = await getPage(page_id, true);
61476
62083
  const fullBody = page.body?.storage?.value ?? page.body?.value ?? "";
62084
+ const pageVersion = page.version?.number ?? 0;
61477
62085
  const resolvedVersion = version2 === "current" ? page.version?.number ?? 0 : version2;
61478
62086
  if (resolvedVersion <= 0) {
61479
62087
  throw new Error(
@@ -61510,7 +62118,8 @@ ${truncated}${truncationNote(origLen)}`
61510
62118
  versionMessage: version_message ?? "",
61511
62119
  deletedTokens: [],
61512
62120
  operation: "update_page_section",
61513
- clientLabel: getClientLabel(server)
62121
+ clientLabel: getClientLabel(server),
62122
+ cloudId
61514
62123
  });
61515
62124
  const warnings2 = [];
61516
62125
  const labelResult2 = await ensureAttributionLabel(submitted2.page.id);
@@ -61527,16 +62136,36 @@ ${truncated}${truncationNote(origLen)}`
61527
62136
  }
61528
62137
  if (confirm_deletions) {
61529
62138
  const deletionSummary = tryForecastDeletions(currentSectionBody, body, cfg.url);
61530
- await gateOperation(server, {
62139
+ const diffHash = cloudId && pageVersion > 0 ? computeDiffHash(body ?? currentSectionBody, pageVersion) : void 0;
62140
+ const tokenResult = await maybeConsumeConfirmToken({
62141
+ confirm_token,
61531
62142
  tool: "update_page_section",
61532
- summary: `Update section "${section}" in page ${page_id} with confirm_deletions?`,
61533
- details: {
61534
- page_id,
61535
- section,
61536
- source: "confirm_deletions",
61537
- ...deletionSummary ? { deletionSummary } : {}
61538
- }
62143
+ cloudId,
62144
+ pageId: page_id,
62145
+ pageVersion,
62146
+ diffHash
61539
62147
  });
62148
+ if (tokenResult === "invalid") {
62149
+ throw new ConverterError(
62150
+ "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.",
62151
+ "CONFIRMATION_TOKEN_INVALID"
62152
+ );
62153
+ } else if (tokenResult === "no_token") {
62154
+ await gateOperation(server, {
62155
+ tool: "update_page_section",
62156
+ summary: `Update section "${section}" in page ${page_id} with confirm_deletions?`,
62157
+ details: {
62158
+ page_id,
62159
+ section,
62160
+ source: "confirm_deletions",
62161
+ ...deletionSummary ? { deletionSummary } : {}
62162
+ },
62163
+ cloudId,
62164
+ pageId: page_id,
62165
+ pageVersion,
62166
+ diffHash
62167
+ });
62168
+ }
61540
62169
  }
61541
62170
  const prepared = await safePrepareBody({
61542
62171
  body,
@@ -61563,7 +62192,8 @@ ${truncated}${truncationNote(origLen)}`
61563
62192
  versionMessage: mergedVersionMessage,
61564
62193
  deletedTokens: prepared.deletedTokens,
61565
62194
  operation: "update_page_section",
61566
- clientLabel: getClientLabel(server)
62195
+ clientLabel: getClientLabel(server),
62196
+ cloudId
61567
62197
  });
61568
62198
  const warnings = [];
61569
62199
  const labelResult = await ensureAttributionLabel(submitted.page.id);
@@ -61575,6 +62205,9 @@ ${truncated}${truncationNote(origLen)}`
61575
62205
  appendWarnings(`Updated section "${section}" in: ${submitted.page.title} (ID: ${submitted.page.id}, version: ${submitted.newVersion}${removalNote})`, warnings) + echo
61576
62206
  );
61577
62207
  } catch (err) {
62208
+ if (err instanceof SoftConfirmationRequiredError) {
62209
+ return formatSoftConfirmationResult(err, { pageId: page_id });
62210
+ }
61578
62211
  return toolErrorWithContext(err, { operation: "update_page_section", resource: `page ${page_id}`, profile: config3.profile });
61579
62212
  }
61580
62213
  }
@@ -61584,7 +62217,7 @@ ${truncated}${truncationNote(origLen)}`
61584
62217
  {
61585
62218
  description: describeWithLock(
61586
62219
  withDestructiveWarning(
61587
- "Update multiple sections of a Confluence page atomically in a single version bump. Either every section applies or none do \u2014 if any section's heading is missing, ambiguous, or its body fails to convert, the whole call is rejected and the page is left unchanged. Use this when you need to update 4+ sections in one go without 4 separate version bumps.\n\nSections are matched against the ORIGINAL page contents (not the cumulative-edited state) and applied in input order; sections cannot reference content introduced by an earlier section in the same call.\n\nUse headings_only to find section names first. Note: in spaces with heading auto-numbering enabled, stored heading text contains the prefix (e.g. `1.2. Section`); the matcher accepts either the prefixed or plain form. Section names must be unique within the input list."
62220
+ "Update multiple sections of a Confluence page atomically in a single version bump. Either every section applies or none do \u2014 if any section's heading is missing, ambiguous, or its body fails to convert, the whole call is rejected and the page is left unchanged. Use this when you need to update 4+ sections in one go without 4 separate version bumps.\n\nSections are matched against the ORIGINAL page contents (not the cumulative-edited state) and applied in input order; sections cannot reference content introduced by an earlier section in the same call.\n\nUse headings_only to find section names first. Note: in spaces with heading auto-numbering enabled, stored heading text contains the prefix (e.g. `1.2. Section`); the matcher accepts either the prefixed or plain form. Section names must be unique within the input list.\n\nIf your MCP client does not support in-protocol confirmation, this tool returns `SOFT_CONFIRMATION_REQUIRED` on the first call when destructive flags are set. STOP and ask the user before retrying. If the user approves, re-call this tool with the same parameters plus `confirm_token` from the first response. The token expires after 5 minutes and is invalidated by competing writes. See the 'Soft confirmation' section of `install-agent.md` for the full protocol."
61588
62221
  ),
61589
62222
  config3
61590
62223
  ),
@@ -61597,6 +62230,7 @@ ${truncated}${truncationNote(origLen)}`
61597
62230
  confirm_deletions: external_exports.boolean().default(false).describe(
61598
62231
  "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."
61599
62232
  ),
62233
+ 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."),
61600
62234
  sections: external_exports.array(
61601
62235
  external_exports.object({
61602
62236
  section: external_exports.string().describe("Heading text identifying the section to replace"),
@@ -61610,14 +62244,16 @@ ${truncated}${truncationNote(origLen)}`
61610
62244
  },
61611
62245
  annotations: { destructiveHint: false, idempotentHint: false }
61612
62246
  },
61613
- async ({ page_id, version: version2, version_message, confirm_deletions, sections }) => {
62247
+ async ({ page_id, version: version2, version_message, confirm_deletions, sections, confirm_token }) => {
61614
62248
  const blocked = writeGuard("update_page_sections", config3);
61615
62249
  if (blocked) return blocked;
61616
62250
  try {
61617
62251
  await checkSpaceAllowed({ pageId: page_id });
61618
62252
  const cfg = await getConfig();
62253
+ const cloudId = cfg.sealedCloudId;
61619
62254
  const page = await getPage(page_id, true);
61620
62255
  const fullBody = page.body?.storage?.value ?? page.body?.value ?? "";
62256
+ const pageVersion = page.version?.number ?? 0;
61621
62257
  const resolvedVersion = version2 === "current" ? page.version?.number ?? 0 : version2;
61622
62258
  if (resolvedVersion <= 0) {
61623
62259
  throw new Error(
@@ -61657,16 +62293,37 @@ ${truncated}${truncationNote(origLen)}`
61657
62293
  any = true;
61658
62294
  }
61659
62295
  }
61660
- await gateOperation(server, {
62296
+ const aggregateBody = sections.map((s) => s.body).join("\n");
62297
+ const diffHash = cloudId && pageVersion > 0 ? computeDiffHash(aggregateBody, pageVersion) : void 0;
62298
+ const tokenResult = await maybeConsumeConfirmToken({
62299
+ confirm_token,
61661
62300
  tool: "update_page_sections",
61662
- summary: `Update ${sections.length} section${sections.length === 1 ? "" : "s"} in page ${page_id} with confirm_deletions?`,
61663
- details: {
61664
- page_id,
61665
- section_count: sections.length,
61666
- source: "confirm_deletions",
61667
- ...any ? { deletionSummary: summed } : {}
61668
- }
62301
+ cloudId,
62302
+ pageId: page_id,
62303
+ pageVersion,
62304
+ diffHash
61669
62305
  });
62306
+ if (tokenResult === "invalid") {
62307
+ throw new ConverterError(
62308
+ "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.",
62309
+ "CONFIRMATION_TOKEN_INVALID"
62310
+ );
62311
+ } else if (tokenResult === "no_token") {
62312
+ await gateOperation(server, {
62313
+ tool: "update_page_sections",
62314
+ summary: `Update ${sections.length} section${sections.length === 1 ? "" : "s"} in page ${page_id} with confirm_deletions?`,
62315
+ details: {
62316
+ page_id,
62317
+ section_count: sections.length,
62318
+ source: "confirm_deletions",
62319
+ ...any ? { deletionSummary: summed } : {}
62320
+ },
62321
+ cloudId,
62322
+ pageId: page_id,
62323
+ pageVersion,
62324
+ diffHash
62325
+ });
62326
+ }
61670
62327
  }
61671
62328
  const prepared = await safePrepareMultiSectionBody({
61672
62329
  currentStorage: fullBody,
@@ -61686,7 +62343,8 @@ ${truncated}${truncationNote(origLen)}`
61686
62343
  regeneratedTokens: prepared.aggregatedRegeneratedTokens,
61687
62344
  operation: "update_page_section",
61688
62345
  clientLabel: getClientLabel(server),
61689
- confirmDeletions: confirm_deletions
62346
+ confirmDeletions: confirm_deletions,
62347
+ cloudId
61690
62348
  });
61691
62349
  const warnings = [];
61692
62350
  const labelResult = await ensureAttributionLabel(submitted.page.id);
@@ -61702,6 +62360,9 @@ ${truncated}${truncationNote(origLen)}`
61702
62360
  ) + echo
61703
62361
  );
61704
62362
  } catch (err) {
62363
+ if (err instanceof SoftConfirmationRequiredError) {
62364
+ return formatSoftConfirmationResult(err, { pageId: page_id });
62365
+ }
61705
62366
  if (err instanceof MultiSectionError) {
61706
62367
  return toolError(err);
61707
62368
  }
@@ -61714,7 +62375,7 @@ ${truncated}${truncationNote(origLen)}`
61714
62375
  {
61715
62376
  description: describeWithLock(
61716
62377
  withDestructiveWarning(
61717
- "Insert content at the beginning of an existing Confluence page. The caller provides only the new content \u2014 the server fetches the existing body and handles concatenation. Safer than update_page with replace_body for additive operations.\n\nContent can be GFM markdown or Confluence storage format (auto-detected)."
62378
+ "Insert content at the beginning of an existing Confluence page. The caller provides only the new content \u2014 the server fetches the existing body and handles concatenation. Safer than update_page with replace_body for additive operations.\n\nContent can be GFM markdown or Confluence storage format (auto-detected).\n\nIf your MCP client does not support in-protocol confirmation, this tool returns `SOFT_CONFIRMATION_REQUIRED` on the first call when destructive flags are set. STOP and ask the user before retrying. If the user approves, re-call this tool with the same parameters plus `confirm_token` from the first response. The token expires after 5 minutes and is invalidated by competing writes. See the 'Soft confirmation' section of `install-agent.md` for the full protocol."
61718
62379
  ),
61719
62380
  config3
61720
62381
  ),
@@ -61727,7 +62388,8 @@ ${truncated}${truncationNote(origLen)}`
61727
62388
  separator: external_exports.string().optional().describe("Separator between new and existing content. Max 100 chars, no XML tags. Defaults to blank line (markdown) or empty (storage)."),
61728
62389
  version_message: external_exports.string().optional().describe("Optional version comment"),
61729
62390
  allow_raw_html: external_exports.boolean().default(false).describe("Allow raw HTML inside markdown content (default false)."),
61730
- confluence_base_url: external_exports.string().url().optional().describe("Override the Confluence base URL used by the link rewriter.")
62391
+ confluence_base_url: external_exports.string().url().optional().describe("Override the Confluence base URL used by the link rewriter."),
62392
+ 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.")
61731
62393
  },
61732
62394
  annotations: { destructiveHint: false, idempotentHint: false }
61733
62395
  },
@@ -61742,7 +62404,7 @@ ${truncated}${truncationNote(origLen)}`
61742
62404
  version2,
61743
62405
  content,
61744
62406
  "prepend",
61745
- { separator, versionMessage: version_message ?? "Prepend content", allowRawHtml: allow_raw_html, confluenceBaseUrl: confluence_base_url ?? cfg.url }
62407
+ { separator, versionMessage: version_message ?? "Prepend content", allowRawHtml: allow_raw_html, confluenceBaseUrl: confluence_base_url ?? cfg.url, cloudId: cfg.sealedCloudId }
61746
62408
  );
61747
62409
  const warnings = [];
61748
62410
  const labelResult = await ensureAttributionLabel(page.id);
@@ -61751,6 +62413,9 @@ ${truncated}${truncationNote(origLen)}`
61751
62413
  if (badgeResult.warning) warnings.push(badgeResult.warning);
61752
62414
  return toolResult(appendWarnings(`Prepended to: ${page.title} (ID: ${page.id}, version: ${newVersion}, body: ${oldLen}\u2192${newLen} chars)`, warnings) + echo);
61753
62415
  } catch (err) {
62416
+ if (err instanceof SoftConfirmationRequiredError) {
62417
+ return formatSoftConfirmationResult(err, { pageId: page_id });
62418
+ }
61754
62419
  return toolErrorWithContext(err, { operation: "prepend_to_page", resource: `page ${page_id}`, profile: config3.profile });
61755
62420
  }
61756
62421
  }
@@ -61760,7 +62425,7 @@ ${truncated}${truncationNote(origLen)}`
61760
62425
  {
61761
62426
  description: describeWithLock(
61762
62427
  withDestructiveWarning(
61763
- "Insert content at the end of an existing Confluence page. The caller provides only the new content \u2014 the server fetches the existing body and handles concatenation. Safer than update_page with replace_body for additive operations.\n\nContent can be GFM markdown or Confluence storage format (auto-detected)."
62428
+ "Insert content at the end of an existing Confluence page. The caller provides only the new content \u2014 the server fetches the existing body and handles concatenation. Safer than update_page with replace_body for additive operations.\n\nContent can be GFM markdown or Confluence storage format (auto-detected).\n\nIf your MCP client does not support in-protocol confirmation, this tool returns `SOFT_CONFIRMATION_REQUIRED` on the first call when destructive flags are set. STOP and ask the user before retrying. If the user approves, re-call this tool with the same parameters plus `confirm_token` from the first response. The token expires after 5 minutes and is invalidated by competing writes. See the 'Soft confirmation' section of `install-agent.md` for the full protocol."
61764
62429
  ),
61765
62430
  config3
61766
62431
  ),
@@ -61773,7 +62438,8 @@ ${truncated}${truncationNote(origLen)}`
61773
62438
  separator: external_exports.string().optional().describe("Separator between existing and new content. Max 100 chars, no XML tags. Defaults to blank line (markdown) or empty (storage)."),
61774
62439
  version_message: external_exports.string().optional().describe("Optional version comment"),
61775
62440
  allow_raw_html: external_exports.boolean().default(false).describe("Allow raw HTML inside markdown content (default false)."),
61776
- confluence_base_url: external_exports.string().url().optional().describe("Override the Confluence base URL used by the link rewriter.")
62441
+ confluence_base_url: external_exports.string().url().optional().describe("Override the Confluence base URL used by the link rewriter."),
62442
+ 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.")
61777
62443
  },
61778
62444
  annotations: { destructiveHint: false, idempotentHint: false }
61779
62445
  },
@@ -61788,7 +62454,7 @@ ${truncated}${truncationNote(origLen)}`
61788
62454
  version2,
61789
62455
  content,
61790
62456
  "append",
61791
- { separator, versionMessage: version_message ?? "Append content", allowRawHtml: allow_raw_html, confluenceBaseUrl: confluence_base_url ?? cfg.url }
62457
+ { separator, versionMessage: version_message ?? "Append content", allowRawHtml: allow_raw_html, confluenceBaseUrl: confluence_base_url ?? cfg.url, cloudId: cfg.sealedCloudId }
61792
62458
  );
61793
62459
  const warnings = [];
61794
62460
  const labelResult = await ensureAttributionLabel(page.id);
@@ -61797,6 +62463,9 @@ ${truncated}${truncationNote(origLen)}`
61797
62463
  if (badgeResult.warning) warnings.push(badgeResult.warning);
61798
62464
  return toolResult(appendWarnings(`Appended to: ${page.title} (ID: ${page.id}, version: ${newVersion}, body: ${oldLen}\u2192${newLen} chars)`, warnings) + echo);
61799
62465
  } catch (err) {
62466
+ if (err instanceof SoftConfirmationRequiredError) {
62467
+ return formatSoftConfirmationResult(err, { pageId: page_id });
62468
+ }
61800
62469
  return toolErrorWithContext(err, { operation: "append_to_page", resource: `page ${page_id}`, profile: config3.profile });
61801
62470
  }
61802
62471
  }
@@ -62728,7 +63397,7 @@ ${sectionFenced}`
62728
63397
  {
62729
63398
  description: describeWithLock(
62730
63399
  withDestructiveWarning(
62731
- "Revert a Confluence page to a previous version. Fetches the exact storage-format body from the historical version and pushes it as a new version. This is a lossless revert \u2014 unlike reading get_page_version (which returns sanitized markdown) and passing it to update_page, this preserves all macros, formatting, and rich elements exactly.\n\nThe shrinkage guard applies: if the reverted content is significantly smaller than the current content, you will be asked to confirm."
63400
+ "Revert a Confluence page to a previous version. Fetches the exact storage-format body from the historical version and pushes it as a new version. This is a lossless revert \u2014 unlike reading get_page_version (which returns sanitized markdown) and passing it to update_page, this preserves all macros, formatting, and rich elements exactly.\n\nThe shrinkage guard applies: if the reverted content is significantly smaller than the current content, you will be asked to confirm.\n\nIf your MCP client does not support in-protocol confirmation, this tool returns `SOFT_CONFIRMATION_REQUIRED` on the first call when destructive flags are set. STOP and ask the user before retrying. If the user approves, re-call this tool with the same parameters plus `confirm_token` from the first response. The token expires after 5 minutes and is invalidated by competing writes. See the 'Soft confirmation' section of `install-agent.md` for the full protocol."
62732
63401
  ),
62733
63402
  config3
62734
63403
  ),
@@ -62749,7 +63418,8 @@ ${sectionFenced}`
62749
63418
  version_message: external_exports.string().optional().describe(
62750
63419
  "Optional version comment. Defaults to 'Revert to version N'."
62751
63420
  ),
62752
- source: sourceSchema
63421
+ source: sourceSchema,
63422
+ 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.")
62753
63423
  },
62754
63424
  annotations: { destructiveHint: false, idempotentHint: false }
62755
63425
  },
@@ -62760,7 +63430,8 @@ ${sectionFenced}`
62760
63430
  confirm_shrinkage,
62761
63431
  confirm_structure_loss,
62762
63432
  version_message,
62763
- source
63433
+ source,
63434
+ confirm_token
62764
63435
  }) => {
62765
63436
  const blocked = writeGuard("revert_page", config3);
62766
63437
  if (blocked) return blocked;
@@ -62772,18 +63443,41 @@ ${sectionFenced}`
62772
63443
  targetVersion: target_version
62773
63444
  });
62774
63445
  const effectiveSource = validateSource(source, flagsSet);
62775
- await gateOperation(server, {
63446
+ const cfg = await getConfig();
63447
+ const cloudId = cfg.sealedCloudId;
63448
+ const pageVersion = current_version;
63449
+ const diffHash = cloudId && pageVersion > 0 ? computeDiffHash("", pageVersion) : void 0;
63450
+ const tokenResult = await maybeConsumeConfirmToken({
63451
+ confirm_token,
62776
63452
  tool: "revert_page",
62777
- summary: `Revert page ${page_id} to version ${target_version}?`,
62778
- details: {
62779
- page_id,
62780
- target_version,
62781
- current_version,
62782
- confirm_shrinkage,
62783
- confirm_structure_loss,
62784
- source: effectiveSource
62785
- }
63453
+ cloudId,
63454
+ pageId: page_id,
63455
+ pageVersion,
63456
+ diffHash
62786
63457
  });
63458
+ if (tokenResult === "invalid") {
63459
+ throw new ConverterError(
63460
+ "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.",
63461
+ "CONFIRMATION_TOKEN_INVALID"
63462
+ );
63463
+ } else if (tokenResult === "no_token") {
63464
+ await gateOperation(server, {
63465
+ tool: "revert_page",
63466
+ summary: `Revert page ${page_id} to version ${target_version}?`,
63467
+ details: {
63468
+ page_id,
63469
+ target_version,
63470
+ current_version,
63471
+ confirm_shrinkage,
63472
+ confirm_structure_loss,
63473
+ source: effectiveSource
63474
+ },
63475
+ cloudId,
63476
+ pageId: page_id,
63477
+ pageVersion,
63478
+ diffHash
63479
+ });
63480
+ }
62787
63481
  const currentPage = await getPage(page_id, true);
62788
63482
  const currentStorage = currentPage.body?.storage?.value ?? currentPage.body?.value ?? "";
62789
63483
  const actualVersion = currentPage.version?.number;
@@ -62818,7 +63512,9 @@ ${sectionFenced}`
62818
63512
  confirmShrinkage: confirm_shrinkage,
62819
63513
  confirmStructureLoss: confirm_structure_loss,
62820
63514
  // E2: thread validated source for the mutation log.
62821
- source: effectiveSource
63515
+ source: effectiveSource,
63516
+ // 2.E: defense-in-depth token invalidation after successful write.
63517
+ cloudId
62822
63518
  });
62823
63519
  const warnings = [];
62824
63520
  const labelResult = await ensureAttributionLabel(submitted.page.id);
@@ -62832,6 +63528,9 @@ ${sectionFenced}`
62832
63528
  ) + echo
62833
63529
  );
62834
63530
  } catch (err) {
63531
+ if (err instanceof SoftConfirmationRequiredError) {
63532
+ return formatSoftConfirmationResult(err, { pageId: page_id });
63533
+ }
62835
63534
  return toolErrorWithContext(err, { operation: "revert_page", resource: `page ${page_id}`, profile: config3.profile });
62836
63535
  }
62837
63536
  }
@@ -62921,7 +63620,7 @@ ${titleFenced}${echo2}`
62921
63620
  inputSchema: {}
62922
63621
  },
62923
63622
  async () => {
62924
- let text2 = `epimethian-mcp v${"6.4.1"}`;
63623
+ let text2 = `epimethian-mcp v${"6.6.0"}`;
62925
63624
  try {
62926
63625
  const pending = await getPendingUpdate();
62927
63626
  if (pending) {
@@ -62952,7 +63651,7 @@ ${label} update available: v${pending.current} \u2192 v${pending.latest}. Run \`
62952
63651
  const pending = await getPendingUpdate();
62953
63652
  if (!pending) {
62954
63653
  return toolResult(
62955
- `epimethian-mcp v${"6.4.1"} is already up to date.`
63654
+ `epimethian-mcp v${"6.6.0"} is already up to date.`
62956
63655
  );
62957
63656
  }
62958
63657
  const output = await performUpgrade(pending.latest);
@@ -62974,7 +63673,7 @@ async function startRecoveryServer(profile) {
62974
63673
  const server = new McpServer(
62975
63674
  {
62976
63675
  name: `confluence-${profile}-setup-needed`,
62977
- version: "6.4.1"
63676
+ version: "6.6.0"
62978
63677
  },
62979
63678
  {
62980
63679
  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.`
@@ -63025,21 +63724,21 @@ async function main() {
63025
63724
  const serverName = config3.profile ? `confluence-${config3.profile}` : "confluence";
63026
63725
  const server = new McpServer({
63027
63726
  name: serverName,
63028
- version: "6.4.1"
63727
+ version: "6.6.0"
63029
63728
  });
63030
63729
  await registerTools(server, config3);
63031
63730
  const transport = new StdioServerTransport();
63032
63731
  await server.connect(transport);
63033
63732
  try {
63034
63733
  const pending = await getPendingUpdate();
63035
- if (pending && pending.current === "6.4.1") {
63734
+ if (pending && pending.current === "6.6.0") {
63036
63735
  console.error(
63037
63736
  `epimethian-mcp: update available: v${pending.current} \u2192 v${pending.latest} (${pending.type}). Run \`epimethian-mcp upgrade\` to install.`
63038
63737
  );
63039
63738
  }
63040
63739
  } catch {
63041
63740
  }
63042
- checkForUpdates("6.4.1").catch(() => {
63741
+ checkForUpdates("6.6.0").catch(() => {
63043
63742
  });
63044
63743
  }
63045
63744
 
@@ -63049,8 +63748,10 @@ async function run() {
63049
63748
  if (command === "setup") {
63050
63749
  const idx = process.argv.indexOf("--profile");
63051
63750
  const profile = idx > -1 ? process.argv[idx + 1] : void 0;
63751
+ const clientIdx = process.argv.indexOf("--client");
63752
+ const clientId = clientIdx > -1 ? process.argv[clientIdx + 1] : void 0;
63052
63753
  const { runSetup: runSetup2 } = await Promise.resolve().then(() => (init_setup(), setup_exports));
63053
- await runSetup2(profile);
63754
+ await runSetup2(profile, clientId);
63054
63755
  } else if (command === "profiles") {
63055
63756
  const { runProfiles: runProfiles2 } = await Promise.resolve().then(() => (init_profiles2(), profiles_exports));
63056
63757
  await runProfiles2();