@de-otio/epimethian-mcp 5.0.0 → 5.1.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
@@ -24073,14 +24073,14 @@ var require_turndown_cjs = __commonJS({
24073
24073
  } else if (node.nodeType === 1) {
24074
24074
  replacement = replacementForNode.call(self, node);
24075
24075
  }
24076
- return join3(output, replacement);
24076
+ return join4(output, replacement);
24077
24077
  }, "");
24078
24078
  }
24079
24079
  function postProcess3(output) {
24080
24080
  var self = this;
24081
24081
  this.rules.forEach(function(rule) {
24082
24082
  if (typeof rule.append === "function") {
24083
- output = join3(output, rule.append(self.options));
24083
+ output = join4(output, rule.append(self.options));
24084
24084
  }
24085
24085
  });
24086
24086
  return output.replace(/^[\t\r\n]+/, "").replace(/[\t\r\n\s]+$/, "");
@@ -24092,7 +24092,7 @@ var require_turndown_cjs = __commonJS({
24092
24092
  if (whitespace.leading || whitespace.trailing) content = content.trim();
24093
24093
  return whitespace.leading + rule.replacement(content, node, this.options) + whitespace.trailing;
24094
24094
  }
24095
- function join3(output, replacement) {
24095
+ function join4(output, replacement) {
24096
24096
  var s1 = trimTrailingNewlines(output);
24097
24097
  var s2 = trimLeadingNewlines(replacement);
24098
24098
  var nls = Math.max(output.length - s1.length, replacement.length - s2.length);
@@ -34605,7 +34605,7 @@ var init_status = __esm({
34605
34605
  var install_agent_default;
34606
34606
  var init_install_agent = __esm({
34607
34607
  "install-agent.md"() {
34608
- 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## Available Tools (29)\n\n| Tool | Description |\n|------|-------------|\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 |\n| `delete_page` | Delete a page |\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 |\n';
34608
+ 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## Available Tools (32)\n\n| Tool | Description |\n|------|-------------|\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 |\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 |\n';
34609
34609
  }
34610
34610
  });
34611
34611
 
@@ -48842,7 +48842,7 @@ var StdioServerTransport = class {
48842
48842
  // src/server/index.ts
48843
48843
  var import_promises2 = require("node:fs/promises");
48844
48844
  var import_node_os2 = require("node:os");
48845
- var import_node_path2 = require("node:path");
48845
+ var import_node_path3 = require("node:path");
48846
48846
 
48847
48847
  // src/server/confluence-client.ts
48848
48848
  var import_turndown = __toESM(require_turndown_cjs());
@@ -48853,9 +48853,13 @@ init_test_connection();
48853
48853
  // src/server/page-cache.ts
48854
48854
  var PageCache = class {
48855
48855
  cache = /* @__PURE__ */ new Map();
48856
+ /** Separate map for pre-write snapshots to avoid eviction pressure (Finding 10). */
48857
+ snapshots = /* @__PURE__ */ new Map();
48856
48858
  maxSize;
48857
- constructor(maxSize = 50) {
48859
+ maxSnapshotSize;
48860
+ constructor(maxSize = 50, maxSnapshotSize = 30) {
48858
48861
  this.maxSize = maxSize;
48862
+ this.maxSnapshotSize = maxSnapshotSize;
48859
48863
  }
48860
48864
  /** Return cached body if page_id exists and version matches. */
48861
48865
  get(pageId, version2) {
@@ -48913,13 +48917,44 @@ var PageCache = class {
48913
48917
  delete(pageId) {
48914
48918
  this.cache.delete(pageId);
48915
48919
  }
48920
+ /**
48921
+ * Store a pre-write snapshot of the page body before a mutation.
48922
+ * Uses a separate map from the main cache to avoid eviction pressure
48923
+ * and key-collision attacks (Finding 10).
48924
+ */
48925
+ setSnapshot(pageId, version2, body) {
48926
+ const key = `${pageId}:${version2}`;
48927
+ this.snapshots.delete(key);
48928
+ if (this.snapshots.size >= this.maxSnapshotSize) {
48929
+ const oldest = this.snapshots.keys().next().value;
48930
+ this.snapshots.delete(oldest);
48931
+ }
48932
+ this.snapshots.set(key, { version: version2, body });
48933
+ }
48934
+ /**
48935
+ * Retrieve a pre-write snapshot for a specific page and version.
48936
+ */
48937
+ getSnapshot(pageId, version2) {
48938
+ const key = `${pageId}:${version2}`;
48939
+ const entry = this.snapshots.get(key);
48940
+ if (entry) {
48941
+ this.snapshots.delete(key);
48942
+ this.snapshots.set(key, entry);
48943
+ return entry.body;
48944
+ }
48945
+ return void 0;
48946
+ }
48916
48947
  /** Empty the cache. */
48917
48948
  clear() {
48918
48949
  this.cache.clear();
48950
+ this.snapshots.clear();
48919
48951
  }
48920
48952
  get size() {
48921
48953
  return this.cache.size;
48922
48954
  }
48955
+ get snapshotSize() {
48956
+ return this.snapshots.size;
48957
+ }
48923
48958
  };
48924
48959
  var pageCache = new PageCache();
48925
48960
 
@@ -49239,7 +49274,7 @@ async function createPage(spaceId, title, body, parentId) {
49239
49274
  representation: "storage",
49240
49275
  value: pageBody
49241
49276
  },
49242
- version: { message: `Created by Epimethian v${"5.0.0"}` }
49277
+ version: { message: `Created by Epimethian v${"5.1.0"}` }
49243
49278
  };
49244
49279
  if (parentId) payload.parentId = parentId;
49245
49280
  const raw = await v2Post("/pages", payload);
@@ -49254,7 +49289,7 @@ async function createPage(spaceId, title, body, parentId) {
49254
49289
  async function updatePage(pageId, opts) {
49255
49290
  const cfg = await getConfig();
49256
49291
  const newVersion = opts.version + 1;
49257
- const versionMessage = opts.versionMessage ? `${opts.versionMessage} (via Epimethian v${"5.0.0"})` : `Updated by Epimethian v${"5.0.0"}`;
49292
+ const versionMessage = opts.versionMessage ? `${opts.versionMessage} (via Epimethian v${"5.1.0"})` : `Updated by Epimethian v${"5.1.0"}`;
49258
49293
  const payload = {
49259
49294
  id: pageId,
49260
49295
  status: "current",
@@ -49269,6 +49304,9 @@ async function updatePage(pageId, opts) {
49269
49304
  value: pageBody
49270
49305
  };
49271
49306
  }
49307
+ if (opts.previousBody !== void 0) {
49308
+ pageCache.setSnapshot(pageId, opts.version, opts.previousBody);
49309
+ }
49272
49310
  let raw;
49273
49311
  try {
49274
49312
  raw = await v2Put(`/pages/${pageId}`, payload);
@@ -49462,7 +49500,7 @@ var ATTRIBUTION_LABEL = "epimethian-managed";
49462
49500
  var ATTRIBUTION_START = "<!-- epimethian-attribution-start -->";
49463
49501
  var ATTRIBUTION_END = "<!-- epimethian-attribution-end -->";
49464
49502
  function buildAttributionFooter(action) {
49465
- return ATTRIBUTION_START + `<p style="font-size:9px;color:#999;margin-top:2em;"><em>This page was ${action} with <a href="${GITHUB_URL}">Epimethian</a> v${"5.0.0"}.</em></p>` + ATTRIBUTION_END;
49503
+ return ATTRIBUTION_START + `<p style="font-size:9px;color:#999;margin-top:2em;"><em>This page was ${action} with <a href="${GITHUB_URL}">Epimethian</a> v${"5.1.0"}.</em></p>` + ATTRIBUTION_END;
49466
49504
  }
49467
49505
  function stripAttributionFooter(body) {
49468
49506
  return body.replace(
@@ -55842,6 +55880,9 @@ var ConverterError = class extends Error {
55842
55880
  this.name = "ConverterError";
55843
55881
  }
55844
55882
  };
55883
+ var SHRINKAGE_NOT_CONFIRMED = "SHRINKAGE_NOT_CONFIRMED";
55884
+ var STRUCTURE_LOSS_NOT_CONFIRMED = "STRUCTURE_LOSS_NOT_CONFIRMED";
55885
+ var EMPTY_BODY_REJECTED = "EMPTY_BODY_REJECTED";
55845
55886
 
55846
55887
  // src/server/converter/md-to-storage.ts
55847
55888
  var MAX_INPUT_BYTES = 1048576;
@@ -56688,6 +56729,49 @@ function planUpdate(params) {
56688
56729
  };
56689
56730
  }
56690
56731
 
56732
+ // src/server/converter/content-safety-guards.ts
56733
+ var SHRINKAGE_GUARD_MIN_OLD_LEN = 500;
56734
+ var SHRINKAGE_GUARD_MAX_RATIO = 0.5;
56735
+ var STRUCTURE_GUARD_MIN_OLD_HEADINGS = 3;
56736
+ var STRUCTURE_GUARD_MAX_RATIO = 0.5;
56737
+ var EMPTY_BODY_MIN_OLD_LEN = 500;
56738
+ var EMPTY_BODY_MIN_TEXT_LEN = 100;
56739
+ var HEADING_RE = /<h[1-6][^>]*>/gi;
56740
+ function countHeadings(storage) {
56741
+ const cleaned = storage.replace(/<ac:plain-text-body>[\s\S]*?<\/ac:plain-text-body>/g, "").replace(/<!--[\s\S]*?-->/g, "");
56742
+ return (cleaned.match(HEADING_RE) || []).length;
56743
+ }
56744
+ function extractTextContent(storage) {
56745
+ return storage.replace(/<!--[\s\S]*?-->/g, "").replace(/<[^>]*>/g, "").replace(/&[a-zA-Z]+;/g, " ").replace(/&#x?[0-9a-fA-F]+;/g, " ").trim();
56746
+ }
56747
+ function enforceContentSafetyGuards(input) {
56748
+ const { oldStorage, newStorage, confirmShrinkage, confirmStructureLoss } = input;
56749
+ const oldLen = oldStorage.length;
56750
+ const newLen = newStorage.length;
56751
+ if (oldLen > SHRINKAGE_GUARD_MIN_OLD_LEN && newLen < oldLen * SHRINKAGE_GUARD_MAX_RATIO && !confirmShrinkage) {
56752
+ const pct = Math.round((1 - newLen / oldLen) * 100);
56753
+ throw new ConverterError(
56754
+ `Body would shrink from ${oldLen} to ${newLen} characters (${pct}% reduction). This may indicate accidental content loss. Re-submit with confirm_shrinkage: true to proceed, or omit replace_body to use token-aware preservation.`,
56755
+ SHRINKAGE_NOT_CONFIRMED
56756
+ );
56757
+ }
56758
+ const oldHeadings = countHeadings(oldStorage);
56759
+ const newHeadings = countHeadings(newStorage);
56760
+ if (oldHeadings >= STRUCTURE_GUARD_MIN_OLD_HEADINGS && newHeadings < oldHeadings * STRUCTURE_GUARD_MAX_RATIO && !confirmStructureLoss) {
56761
+ throw new ConverterError(
56762
+ `Heading count would drop from ${oldHeadings} to ${newHeadings}. This may indicate accidental content loss. Re-submit with confirm_structure_loss: true to proceed.`,
56763
+ STRUCTURE_LOSS_NOT_CONFIRMED
56764
+ );
56765
+ }
56766
+ const textContent = extractTextContent(newStorage);
56767
+ if (oldLen > EMPTY_BODY_MIN_OLD_LEN && textContent.length < EMPTY_BODY_MIN_TEXT_LEN) {
56768
+ throw new ConverterError(
56769
+ `New body contains only ${textContent.length} characters of text content (old body: ${oldLen} characters). This almost certainly indicates accidental content loss. To intentionally clear a page, use delete_page and re-create it.`,
56770
+ EMPTY_BODY_REJECTED
56771
+ );
56772
+ }
56773
+ }
56774
+
56691
56775
  // src/server/converter/storage-to-md.ts
56692
56776
  var import_turndown2 = __toESM(require_turndown_cjs());
56693
56777
  function makeTurndown() {
@@ -56713,6 +56797,73 @@ function storageToMarkdown(storage) {
56713
56797
  return { markdown, sidecar };
56714
56798
  }
56715
56799
 
56800
+ // src/server/mutation-log.ts
56801
+ var import_node_fs = require("node:fs");
56802
+ var import_node_path2 = require("node:path");
56803
+ var MAX_LOG_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
56804
+ var MAX_ERROR_LEN = 200;
56805
+ var logPath = null;
56806
+ var logFd = null;
56807
+ function sanitizeErrorMessage(err) {
56808
+ const msg = err instanceof Error ? err.message : String(err);
56809
+ const firstLine = msg.split("\n")[0];
56810
+ return firstLine.length > MAX_ERROR_LEN ? firstLine.slice(0, MAX_ERROR_LEN) + "..." : firstLine;
56811
+ }
56812
+ function sweepOldLogs(dir) {
56813
+ try {
56814
+ const now = Date.now();
56815
+ for (const name of (0, import_node_fs.readdirSync)(dir)) {
56816
+ if (!name.startsWith("mutations-") || !name.endsWith(".jsonl")) continue;
56817
+ const filePath = (0, import_node_path2.join)(dir, name);
56818
+ try {
56819
+ const stat2 = (0, import_node_fs.statSync)(filePath);
56820
+ if (now - stat2.mtimeMs > MAX_LOG_AGE_MS) {
56821
+ (0, import_node_fs.unlinkSync)(filePath);
56822
+ }
56823
+ } catch {
56824
+ }
56825
+ }
56826
+ } catch {
56827
+ }
56828
+ }
56829
+ function initMutationLog(dir) {
56830
+ if (!(0, import_node_fs.existsSync)(dir)) {
56831
+ (0, import_node_fs.mkdirSync)(dir, { recursive: true, mode: 448 });
56832
+ } else {
56833
+ const stat2 = (0, import_node_fs.lstatSync)(dir);
56834
+ if (stat2.isSymbolicLink()) {
56835
+ throw new Error(
56836
+ `Mutation log directory ${dir} is a symlink \u2014 refusing to write. Remove the symlink and restart.`
56837
+ );
56838
+ }
56839
+ }
56840
+ sweepOldLogs(dir);
56841
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
56842
+ logPath = (0, import_node_path2.join)(dir, `mutations-${ts}.jsonl`);
56843
+ try {
56844
+ logFd = (0, import_node_fs.openSync)(logPath, "ax", 384);
56845
+ } catch {
56846
+ logFd = (0, import_node_fs.openSync)(logPath, "a", 384);
56847
+ }
56848
+ }
56849
+ function logMutation(record2) {
56850
+ if (logFd === null) return;
56851
+ try {
56852
+ const line = JSON.stringify(record2) + "\n";
56853
+ (0, import_node_fs.writeSync)(logFd, line);
56854
+ } catch {
56855
+ }
56856
+ }
56857
+ function errorRecord(operation, pageId, err, extra) {
56858
+ return {
56859
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
56860
+ operation,
56861
+ pageId,
56862
+ error: sanitizeErrorMessage(err),
56863
+ ...extra
56864
+ };
56865
+ }
56866
+
56716
56867
  // src/server/index.ts
56717
56868
  function escapeXml(s) {
56718
56869
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
@@ -56845,6 +56996,38 @@ function registerTools(server, config3) {
56845
56996
  "Labels with the 'epimethian-' prefix are system-managed and cannot be modified directly"
56846
56997
  );
56847
56998
  const pageIdSchema = external_exports.string().regex(/^\d+$/, "Page ID must be numeric");
56999
+ async function concatPageContent(page_id, version2, newContent, position, opts = {}) {
57000
+ const currentPage = await getPage(page_id, true);
57001
+ const currentStorage = currentPage.body?.storage?.value ?? currentPage.body?.value ?? "";
57002
+ const isMarkdown = looksLikeMarkdown(newContent);
57003
+ const contentStorage = isMarkdown ? markdownToStorage(newContent, {
57004
+ allowRawHtml: opts.allowRawHtml,
57005
+ confluenceBaseUrl: opts.confluenceBaseUrl
57006
+ }) : newContent;
57007
+ let sep;
57008
+ if (opts.separator !== void 0) {
57009
+ sep = opts.separator;
57010
+ } else {
57011
+ sep = isMarkdown ? "\n\n" : "";
57012
+ }
57013
+ if (sep.length > 100) {
57014
+ throw new Error("separator must be 100 characters or fewer");
57015
+ }
57016
+ if (sep.includes("<")) {
57017
+ throw new Error("separator must not contain XML/HTML tags (no '<' characters)");
57018
+ }
57019
+ if (currentStorage.length + contentStorage.length + sep.length > 2e6) {
57020
+ throw new Error("Combined body exceeds 2MB limit");
57021
+ }
57022
+ const newBody = position === "prepend" ? contentStorage + sep + currentStorage : currentStorage + sep + contentStorage;
57023
+ const { page, newVersion } = await updatePage(page_id, {
57024
+ title: currentPage.title,
57025
+ body: newBody,
57026
+ version: version2,
57027
+ versionMessage: opts.versionMessage
57028
+ });
57029
+ return { page, newVersion, oldLen: currentStorage.length, newLen: newBody.length };
57030
+ }
56848
57031
  server.registerTool(
56849
57032
  "create_page",
56850
57033
  {
@@ -56878,11 +57061,20 @@ function registerTools(server, config3) {
56878
57061
  }
56879
57062
  const spaceId = await resolveSpaceId(space_key);
56880
57063
  const page = await createPage(spaceId, title, finalBody, parent_id);
57064
+ logMutation({
57065
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
57066
+ operation: "create_page",
57067
+ pageId: page.id,
57068
+ newVersion: page.version?.number ?? 1,
57069
+ newBodyLen: finalBody.length
57070
+ });
56881
57071
  return toolResult(await formatPage(page, false) + echo);
56882
57072
  } catch (err) {
56883
57073
  if (err instanceof ConverterError) {
57074
+ logMutation(errorRecord("create_page", "unknown", err));
56884
57075
  return toolError(err);
56885
57076
  }
57077
+ logMutation(errorRecord("create_page", "unknown", err));
56886
57078
  return toolError(err);
56887
57079
  }
56888
57080
  }
@@ -56977,7 +57169,7 @@ ${truncated}`);
56977
57169
  "update_page",
56978
57170
  {
56979
57171
  description: describeWithLock(
56980
- "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. 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- allow_raw_html: allow raw HTML inside markdown bodies (default false).\n- confluence_base_url: override the URL used by the link rewriter.",
57172
+ "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. 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.",
56981
57173
  config3
56982
57174
  ),
56983
57175
  inputSchema: {
@@ -56988,19 +57180,27 @@ ${truncated}`);
56988
57180
  version_message: external_exports.string().optional().describe("Optional version comment"),
56989
57181
  confirm_deletions: external_exports.boolean().default(false).describe("Set to true to acknowledge that your markdown removes preserved macros or rich elements. Required when any preserved element would be deleted."),
56990
57182
  replace_body: external_exports.boolean().default(false).describe("Set to true for a wholesale page rewrite that skips token preservation. All existing macros will be lost. Use only when intentionally replacing the full body."),
57183
+ confirm_shrinkage: external_exports.boolean().default(false).describe(
57184
+ "Set to true to acknowledge that the new body is significantly smaller than the existing body. Required when the body would shrink by more than 50%."
57185
+ ),
57186
+ confirm_structure_loss: external_exports.boolean().default(false).describe(
57187
+ "Set to true to acknowledge that the new body has significantly fewer headings than the existing body. Required when heading count would drop by more than 50%."
57188
+ ),
56991
57189
  allow_raw_html: external_exports.boolean().default(false).describe("Allow raw HTML passthrough inside markdown bodies (disabled by default)."),
56992
57190
  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.")
56993
57191
  },
56994
57192
  annotations: { destructiveHint: false, idempotentHint: false }
56995
57193
  },
56996
- async ({ page_id, title, version: version2, body, version_message, confirm_deletions, replace_body, allow_raw_html, confluence_base_url }) => {
57194
+ async ({ page_id, title, version: version2, body, version_message, confirm_deletions, replace_body, confirm_shrinkage, confirm_structure_loss, allow_raw_html, confluence_base_url }) => {
56997
57195
  const blocked = writeGuard("update_page", config3);
56998
57196
  if (blocked) return blocked;
56999
57197
  try {
57198
+ const cfg = await getConfig();
57199
+ const currentPage = await getPage(page_id, true);
57200
+ const currentStorage = currentPage.body?.storage?.value ?? currentPage.body?.value ?? "";
57201
+ let finalStorage;
57202
+ let effectiveVersionMessage = version_message;
57000
57203
  if (body && looksLikeMarkdown(body)) {
57001
- const cfg = await getConfig();
57002
- const currentPage = await getPage(page_id, true);
57003
- const currentStorage = currentPage.body?.storage?.value ?? currentPage.body?.value ?? "";
57004
57204
  const plan = planUpdate({
57005
57205
  currentStorage,
57006
57206
  callerMarkdown: body,
@@ -57011,30 +57211,42 @@ ${truncated}`);
57011
57211
  confluenceBaseUrl: confluence_base_url ?? cfg.url
57012
57212
  }
57013
57213
  });
57014
- const effectiveVersionMessage = plan.versionMessage && version_message ? `${version_message}; ${plan.versionMessage}` : plan.versionMessage ?? version_message;
57015
- const { page: page2, newVersion: newVersion2 } = await updatePage(page_id, {
57016
- title,
57017
- body: plan.newStorage,
57018
- version: version2,
57019
- versionMessage: effectiveVersionMessage
57020
- });
57021
- return toolResult(
57022
- `Updated: ${page2.title} (ID: ${page2.id}, version: ${newVersion2})` + echo
57023
- );
57214
+ finalStorage = plan.newStorage;
57215
+ effectiveVersionMessage = plan.versionMessage && version_message ? `${version_message}; ${plan.versionMessage}` : plan.versionMessage ?? version_message;
57216
+ } else {
57217
+ finalStorage = body ?? "";
57024
57218
  }
57219
+ enforceContentSafetyGuards({
57220
+ oldStorage: currentStorage,
57221
+ newStorage: finalStorage,
57222
+ confirmShrinkage: confirm_shrinkage,
57223
+ confirmStructureLoss: confirm_structure_loss
57224
+ });
57025
57225
  const { page, newVersion } = await updatePage(page_id, {
57026
57226
  title,
57027
- body,
57227
+ body: finalStorage,
57028
57228
  version: version2,
57029
- versionMessage: version_message
57229
+ versionMessage: effectiveVersionMessage,
57230
+ previousBody: currentStorage
57231
+ });
57232
+ logMutation({
57233
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
57234
+ operation: "update_page",
57235
+ pageId: page_id,
57236
+ oldVersion: version2,
57237
+ newVersion,
57238
+ oldBodyLen: currentStorage.length,
57239
+ newBodyLen: finalStorage.length,
57240
+ replaceBody: replace_body || void 0
57030
57241
  });
57031
57242
  return toolResult(
57032
- `Updated: ${page.title} (ID: ${page.id}, version: ${newVersion})` + echo
57243
+ `Updated: ${page.title} (ID: ${page.id}, version: ${newVersion}, body: ${currentStorage.length}\u2192${finalStorage.length} chars)` + echo
57033
57244
  );
57034
57245
  } catch (err) {
57035
- if (err instanceof ConverterError) {
57036
- return toolError(err);
57037
- }
57246
+ logMutation(errorRecord("update_page", page_id, err, {
57247
+ oldVersion: version2,
57248
+ replaceBody: replace_body || void 0
57249
+ }));
57038
57250
  return toolError(err);
57039
57251
  }
57040
57252
  }
@@ -57053,8 +57265,14 @@ ${truncated}`);
57053
57265
  if (blocked) return blocked;
57054
57266
  try {
57055
57267
  await deletePage(page_id);
57268
+ logMutation({
57269
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
57270
+ operation: "delete_page",
57271
+ pageId: page_id
57272
+ });
57056
57273
  return toolResult(`Deleted page ${page_id}` + echo);
57057
57274
  } catch (err) {
57275
+ logMutation(errorRecord("delete_page", page_id, err));
57058
57276
  return toolError(err);
57059
57277
  }
57060
57278
  }
@@ -57091,12 +57309,99 @@ ${truncated}`);
57091
57309
  title: page.title,
57092
57310
  body: newFullBody,
57093
57311
  version: version2,
57094
- versionMessage: version_message
57312
+ versionMessage: version_message,
57313
+ previousBody: fullBody
57314
+ });
57315
+ logMutation({
57316
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
57317
+ operation: "update_page",
57318
+ pageId: page_id,
57319
+ oldVersion: version2,
57320
+ newVersion,
57321
+ oldBodyLen: fullBody.length,
57322
+ newBodyLen: newFullBody.length
57095
57323
  });
57096
57324
  return toolResult(
57097
57325
  `Updated section "${section}" in: ${updated.title} (ID: ${updated.id}, version: ${newVersion})` + echo
57098
57326
  );
57099
57327
  } catch (err) {
57328
+ logMutation(errorRecord("update_page", page_id, err, { oldVersion: version2 }));
57329
+ return toolError(err);
57330
+ }
57331
+ }
57332
+ );
57333
+ server.registerTool(
57334
+ "prepend_to_page",
57335
+ {
57336
+ description: describeWithLock(
57337
+ "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).",
57338
+ config3
57339
+ ),
57340
+ inputSchema: {
57341
+ page_id: external_exports.string().describe("The Confluence page ID"),
57342
+ version: external_exports.number().int().positive().describe("Page version from your most recent get_page call"),
57343
+ content: external_exports.string().describe("Content to insert before the existing body. GFM markdown or storage format (auto-detected)."),
57344
+ 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)."),
57345
+ version_message: external_exports.string().optional().describe("Optional version comment"),
57346
+ allow_raw_html: external_exports.boolean().default(false).describe("Allow raw HTML inside markdown content (default false)."),
57347
+ confluence_base_url: external_exports.string().url().optional().describe("Override the Confluence base URL used by the link rewriter.")
57348
+ },
57349
+ annotations: { destructiveHint: false, idempotentHint: false }
57350
+ },
57351
+ async ({ page_id, version: version2, content, separator, version_message, allow_raw_html, confluence_base_url }) => {
57352
+ const blocked = writeGuard("prepend_to_page", config3);
57353
+ if (blocked) return blocked;
57354
+ try {
57355
+ const cfg = await getConfig();
57356
+ const { page, newVersion, oldLen, newLen } = await concatPageContent(
57357
+ page_id,
57358
+ version2,
57359
+ content,
57360
+ "prepend",
57361
+ { separator, versionMessage: version_message ?? "Prepend content", allowRawHtml: allow_raw_html, confluenceBaseUrl: confluence_base_url ?? cfg.url }
57362
+ );
57363
+ logMutation({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), operation: "prepend_to_page", pageId: page_id, oldVersion: version2, newVersion, oldBodyLen: oldLen, newBodyLen: newLen });
57364
+ return toolResult(`Prepended to: ${page.title} (ID: ${page.id}, version: ${newVersion}, body: ${oldLen}\u2192${newLen} chars)` + echo);
57365
+ } catch (err) {
57366
+ logMutation(errorRecord("prepend_to_page", page_id, err, { oldVersion: version2 }));
57367
+ return toolError(err);
57368
+ }
57369
+ }
57370
+ );
57371
+ server.registerTool(
57372
+ "append_to_page",
57373
+ {
57374
+ description: describeWithLock(
57375
+ "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).",
57376
+ config3
57377
+ ),
57378
+ inputSchema: {
57379
+ page_id: external_exports.string().describe("The Confluence page ID"),
57380
+ version: external_exports.number().int().positive().describe("Page version from your most recent get_page call"),
57381
+ content: external_exports.string().describe("Content to insert after the existing body. GFM markdown or storage format (auto-detected)."),
57382
+ 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)."),
57383
+ version_message: external_exports.string().optional().describe("Optional version comment"),
57384
+ allow_raw_html: external_exports.boolean().default(false).describe("Allow raw HTML inside markdown content (default false)."),
57385
+ confluence_base_url: external_exports.string().url().optional().describe("Override the Confluence base URL used by the link rewriter.")
57386
+ },
57387
+ annotations: { destructiveHint: false, idempotentHint: false }
57388
+ },
57389
+ async ({ page_id, version: version2, content, separator, version_message, allow_raw_html, confluence_base_url }) => {
57390
+ const blocked = writeGuard("append_to_page", config3);
57391
+ if (blocked) return blocked;
57392
+ try {
57393
+ const cfg = await getConfig();
57394
+ const { page, newVersion, oldLen, newLen } = await concatPageContent(
57395
+ page_id,
57396
+ version2,
57397
+ content,
57398
+ "append",
57399
+ { separator, versionMessage: version_message ?? "Append content", allowRawHtml: allow_raw_html, confluenceBaseUrl: confluence_base_url ?? cfg.url }
57400
+ );
57401
+ logMutation({ timestamp: (/* @__PURE__ */ new Date()).toISOString(), operation: "append_to_page", pageId: page_id, oldVersion: version2, newVersion, oldBodyLen: oldLen, newBodyLen: newLen });
57402
+ return toolResult(`Appended to: ${page.title} (ID: ${page.id}, version: ${newVersion}, body: ${oldLen}\u2192${newLen} chars)` + echo);
57403
+ } catch (err) {
57404
+ logMutation(errorRecord("append_to_page", page_id, err, { oldVersion: version2 }));
57100
57405
  return toolError(err);
57101
57406
  }
57102
57407
  }
@@ -57327,7 +57632,7 @@ ${truncated}`);
57327
57632
  const blocked = writeGuard("add_attachment", config3);
57328
57633
  if (blocked) return blocked;
57329
57634
  try {
57330
- const resolved = await (0, import_promises2.realpath)((0, import_node_path2.resolve)(file_path));
57635
+ const resolved = await (0, import_promises2.realpath)((0, import_node_path3.resolve)(file_path));
57331
57636
  const cwd = await (0, import_promises2.realpath)(process.cwd());
57332
57637
  if (!resolved.startsWith(cwd + "/") && resolved !== cwd) {
57333
57638
  return toolError(
@@ -57376,9 +57681,9 @@ ${truncated}`);
57376
57681
  if (blocked) return blocked;
57377
57682
  try {
57378
57683
  const filename = diagram_name.endsWith(".drawio") ? diagram_name : `${diagram_name}.drawio`;
57379
- const tmpDir = await (0, import_promises2.mkdtemp)((0, import_node_path2.join)((0, import_node_os2.tmpdir)(), "drawio-"));
57684
+ const tmpDir = await (0, import_promises2.mkdtemp)((0, import_node_path3.join)((0, import_node_os2.tmpdir)(), "drawio-"));
57380
57685
  try {
57381
- const tmpPath = (0, import_node_path2.join)(tmpDir, filename);
57686
+ const tmpPath = (0, import_node_path3.join)(tmpDir, filename);
57382
57687
  await (0, import_promises2.writeFile)(tmpPath, diagram_xml, "utf-8");
57383
57688
  const fileData = await (0, import_promises2.readFile)(tmpPath);
57384
57689
  await uploadAttachment(page_id, fileData, filename);
@@ -57763,7 +58068,7 @@ ${lines}`);
57763
58068
  server.registerTool(
57764
58069
  "get_page_version",
57765
58070
  {
57766
- description: "Get the content of a Confluence page at a specific historical version. Returns sanitized markdown (macros replaced with placeholders). Note: historical versions may contain content that was intentionally deleted. Costs 1 API call.",
58071
+ description: "Get the content of a Confluence page at a specific historical version. Returns sanitized markdown (macros replaced with placeholders). Note: historical versions may contain content that was intentionally deleted. Costs 1 API call.\n\nReturns sanitized read-only markdown, NOT raw Confluence storage format. Macros are replaced with placeholders. This content is NOT suitable for round-trip updates via update_page \u2014 the conversion is lossy. To revert a page to a previous version, use revert_page instead.",
57767
58072
  inputSchema: {
57768
58073
  page_id: pageIdSchema.describe("Confluence page ID"),
57769
58074
  version: external_exports.number().int().min(1).describe("Version number to retrieve")
@@ -57868,6 +58173,89 @@ ${result.diff}${truncNote}` + echo
57868
58173
  }
57869
58174
  }
57870
58175
  );
58176
+ server.registerTool(
58177
+ "revert_page",
58178
+ {
58179
+ description: describeWithLock(
58180
+ "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.",
58181
+ config3
58182
+ ),
58183
+ inputSchema: {
58184
+ page_id: pageIdSchema.describe("The Confluence page ID"),
58185
+ target_version: external_exports.number().int().positive().describe(
58186
+ "The version number to revert to. Must be less than the current version."
58187
+ ),
58188
+ current_version: external_exports.number().int().positive().describe(
58189
+ "The current page version from your most recent get_page call (for optimistic locking)."
58190
+ ),
58191
+ confirm_shrinkage: external_exports.boolean().default(false).describe(
58192
+ "Set to true if the historical version is expected to be significantly smaller than the current version."
58193
+ ),
58194
+ confirm_structure_loss: external_exports.boolean().default(false).describe(
58195
+ "Set to true if the historical version has fewer headings than the current version."
58196
+ ),
58197
+ version_message: external_exports.string().optional().describe(
58198
+ "Optional version comment. Defaults to 'Revert to version N'."
58199
+ )
58200
+ },
58201
+ annotations: { destructiveHint: false, idempotentHint: false }
58202
+ },
58203
+ async ({
58204
+ page_id,
58205
+ target_version,
58206
+ current_version,
58207
+ confirm_shrinkage,
58208
+ confirm_structure_loss,
58209
+ version_message
58210
+ }) => {
58211
+ const blocked = writeGuard("revert_page", config3);
58212
+ if (blocked) return blocked;
58213
+ try {
58214
+ const currentPage = await getPage(page_id, true);
58215
+ const currentStorage = currentPage.body?.storage?.value ?? currentPage.body?.value ?? "";
58216
+ const actualVersion = currentPage.version?.number;
58217
+ if (actualVersion !== void 0 && actualVersion !== current_version) {
58218
+ return toolError(
58219
+ new Error(
58220
+ `Version mismatch: expected ${current_version}, but page is at version ${actualVersion}. Re-read the page with get_page and retry with the current version number.`
58221
+ )
58222
+ );
58223
+ }
58224
+ const historical = await getPageVersionBody(page_id, target_version);
58225
+ enforceContentSafetyGuards({
58226
+ oldStorage: currentStorage,
58227
+ newStorage: historical.rawBody,
58228
+ confirmShrinkage: confirm_shrinkage,
58229
+ confirmStructureLoss: confirm_structure_loss
58230
+ });
58231
+ const { page, newVersion } = await updatePage(page_id, {
58232
+ title: currentPage.title,
58233
+ body: historical.rawBody,
58234
+ version: current_version,
58235
+ versionMessage: version_message ?? `Revert to version ${target_version}`
58236
+ });
58237
+ logMutation({
58238
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
58239
+ operation: "revert_page",
58240
+ pageId: page_id,
58241
+ oldVersion: current_version,
58242
+ newVersion,
58243
+ oldBodyLen: currentStorage.length,
58244
+ newBodyLen: historical.rawBody.length
58245
+ });
58246
+ return toolResult(
58247
+ `Reverted: ${page.title} (ID: ${page.id}, v${target_version}\u2192v${newVersion}, body: ${currentStorage.length}\u2192${historical.rawBody.length} chars)` + echo
58248
+ );
58249
+ } catch (err) {
58250
+ logMutation(
58251
+ errorRecord("revert_page", page_id, err, {
58252
+ oldVersion: current_version
58253
+ })
58254
+ );
58255
+ return toolError(err);
58256
+ }
58257
+ }
58258
+ );
57871
58259
  server.registerTool(
57872
58260
  "lookup_user",
57873
58261
  {
@@ -57935,16 +58323,20 @@ ${lines.join("\n")}${echo2}`
57935
58323
  description: "Return the epimethian-mcp server version.",
57936
58324
  inputSchema: {}
57937
58325
  },
57938
- async () => toolResult(`epimethian-mcp v${"5.0.0"}`)
58326
+ async () => toolResult(`epimethian-mcp v${"5.1.0"}`)
57939
58327
  );
57940
58328
  }
57941
58329
  async function main() {
57942
58330
  const config3 = await getConfig();
57943
58331
  await validateStartup(config3);
58332
+ if (process.env.EPIMETHIAN_MUTATION_LOG === "true") {
58333
+ const logDir = (0, import_node_path3.join)((0, import_node_os2.homedir)(), ".epimethian", "logs");
58334
+ initMutationLog(logDir);
58335
+ }
57944
58336
  const serverName = config3.profile ? `confluence-${config3.profile}` : "confluence";
57945
58337
  const server = new McpServer({
57946
58338
  name: serverName,
57947
- version: "5.0.0"
58339
+ version: "5.1.0"
57948
58340
  });
57949
58341
  registerTools(server, config3);
57950
58342
  const transport = new StdioServerTransport();