diff --git a/src/mcp_atlassian/preprocessing/jira.py b/src/mcp_atlassian/preprocessing/jira.py
index 31d422936..b45db04dc 100644
--- a/src/mcp_atlassian/preprocessing/jira.py
+++ b/src/mcp_atlassian/preprocessing/jira.py
@@ -1,7 +1,9 @@
"""Jira-specific text preprocessing module."""
+import html
import logging
import re
+import uuid
from typing import Any
from .base import BasePreprocessor, _extract_blocks, _restore_blocks
@@ -24,6 +26,153 @@ def _convert_panel(params: str | None, content: str) -> str:
return f"\n{content}\n"
+# Jira admonition macros ({info}, {note}, {warning}, {tip}) share the {panel}
+# param form (title=...). The macro type carries meaning (info vs warning), so
+# it is kept as a bold label prefix on the converted heading. Conversion is
+# read-only (Jira -> Markdown), matching how {panel} is handled.
+_ADMONITION_LABELS = {
+ "info": "ℹ️ Info",
+ "note": "📝 Note",
+ "warning": "⚠️ Warning",
+ "tip": "💡 Tip",
+}
+
+# Label for the {expand} collapsible macro; the ▸ marks it as expandable.
+_EXPAND_LABEL = "▸ Expand"
+
+# Template for the temporary sentinel wrapping a heading generated from a Jira
+# macro. The UUID is selected per input so user-authored text cannot forge the
+# exact marker and bypass the HTML->markdown pass.
+_MACRO_HEADING_MARK = "\x00JMH:{}\x00"
+
+
+def _new_macro_heading_mark(text: str) -> str:
+ """Return a temporary marker that cannot occur in the source text."""
+ while True:
+ marker = _MACRO_HEADING_MARK.format(uuid.uuid4().hex)
+ if marker not in text:
+ return marker
+
+
+def _normalize_macro_title(title: str) -> str:
+ """Normalize whitespace so a macro title remains one Markdown line."""
+ return re.sub(r"\s+", " ", title).strip()
+
+
+def _macro_heading(
+ heading: str, content: str, macro_heading_mark: str | None = None
+) -> str:
+ """Render a macro as a bold heading with optional protection."""
+ content = content.strip()
+ marker = macro_heading_mark or ""
+ return f"\n{marker}**{heading}**{marker}\n{content}\n"
+
+
+def _convert_admonition(
+ macro: str,
+ params: str | None,
+ content: str,
+ macro_heading_mark: str | None = None,
+) -> str:
+ """Convert a Jira {info}/{note}/{warning}/{tip} block to markdown.
+
+ Emits a bold labelled heading followed by the body, e.g.
+ ``**ℹ️ Info:
**\\n`` (or ``**ℹ️ Info**`` when the macro has
+ no title). Mirrors the read-only handling of {panel}. The title is
+ HTML-escaped so angle brackets / HTML-looking text survive the downstream
+ HTML->markdown pass literally instead of being reparsed as tags.
+ """
+ label = _ADMONITION_LABELS[macro]
+ title = ""
+ if params:
+ title_match = re.search(r"title=([^|}]+)", params)
+ if title_match:
+ title = title_match.group(1)
+ title = _normalize_macro_title(title)
+ title = html.escape(title, quote=False)
+ heading = f"{label}: {title}" if title else label
+ return _macro_heading(heading, content, macro_heading_mark)
+
+
+def _convert_expand(
+ title: str | None, content: str, macro_heading_mark: str | None = None
+) -> str:
+ """Convert a Jira {expand} collapsible block to markdown.
+
+ Emits ``**▸ Expand: **\\n`` (or ``**▸ Expand**`` when there
+ is no title). ``{expand:Foo}`` and ``{expand:title=Foo}`` both yield
+ ``Foo``; option-only params such as ``{expand:macro-id=123}`` carry no title
+ and render as plain ``▸ Expand`` rather than exposing the internal option.
+ """
+ raw = (title or "").strip()
+ title_match = re.search(r"(?:^|\|)title=([^|}]+)", raw)
+ if title_match:
+ summary = title_match.group(1)
+ elif raw and "=" not in raw.split("|", 1)[0]:
+ # Shorthand {expand:Foo}: first segment is a title only when it is not
+ # a key=value option (an internal parameter).
+ summary = raw.split("|", 1)[0]
+ else:
+ summary = ""
+ summary = _normalize_macro_title(summary)
+ summary = html.escape(summary, quote=False)
+ heading = f"{_EXPAND_LABEL}: {summary}" if summary else _EXPAND_LABEL
+ return _macro_heading(heading, content, macro_heading_mark)
+
+
+def _convert_macro_blocks(text: str, macro_heading_mark: str | None = None) -> str:
+ """Convert nested Jira admonition and expand macros to Markdown.
+
+ Jira uses the same bare tag for a macro's opening and closing delimiter.
+ A stack identifies the matching close for each supported macro, and the
+ innermost complete block is replaced first so nested macros retain their
+ boundaries while their parent is converted.
+ """
+ macro_tag_pattern = re.compile(r"\{(info|note|warning|tip|expand)(?::([^{}]*))?\}")
+
+ while True:
+ stack: list[tuple[int, int, str, str | None, bool]] = []
+ replacement: tuple[int, int, str, str | None, str] | None = None
+
+ for match in macro_tag_pattern.finditer(text):
+ macro = match.group(1)
+ params = match.group(2)
+
+ # Closing tags have no parameters and must match the macro at the
+ # top of the stack. A parameterized tag is always an opening tag,
+ # which also disambiguates same-type nested macros.
+ is_closing = bool(params is None and stack and stack[-1][2] == macro)
+ if not is_closing:
+ if stack:
+ start, opening_end, open_macro, open_params, _ = stack[-1]
+ stack[-1] = (start, opening_end, open_macro, open_params, True)
+ stack.append((match.start(), match.end(), macro, params, False))
+ continue
+
+ start, opening_end, open_macro, open_params, has_nested = stack.pop()
+ if has_nested:
+ continue
+
+ replacement = (
+ start,
+ match.end(),
+ open_macro,
+ open_params,
+ text[opening_end : match.start()],
+ )
+ break
+
+ if replacement is None:
+ return text
+
+ start, end, macro, params, content = replacement
+ if macro == "expand":
+ converted = _convert_expand(params, content, macro_heading_mark)
+ else:
+ converted = _convert_admonition(macro, params, content, macro_heading_mark)
+ text = text[:start] + converted + text[end:]
+
+
class JiraPreprocessor(BasePreprocessor):
"""Handles text preprocessing for Jira content."""
@@ -142,11 +291,35 @@ def clean_jira_text(self, text: str) -> str:
# Convert markup only if translation is enabled
if not self.disable_translation:
- # First convert any Jira markup to Markdown
- text = self.jira_to_markdown(text)
+ # First convert any Jira markup to Markdown, keeping per-input
+ # macro heading sentinels so they can be protected below.
+ macro_heading_mark = _new_macro_heading_mark(text)
+ text = self.jira_to_markdown(
+ text,
+ _keep_macro_marks=True,
+ _macro_heading_mark=macro_heading_mark,
+ )
+
+ # Protect headings generated from Jira macros from the HTML
+ # conversion pass. The macro converters wrap each generated heading
+ # in a private sentinel, so only those headings are protected —
+ # user-authored bold text that merely starts with the same label is
+ # left for _convert_html_to_markdown to sanitize like any other
+ # content. The sentinel is stripped as the heading is stored.
+ macro_headings: list[str] = []
+ mark = re.escape(macro_heading_mark)
+ text = _extract_blocks(
+ text,
+ rf"{mark}(.*?){mark}",
+ lambda match: match.group(1),
+ macro_headings,
+ "JIRAMACRO",
+ flags=re.DOTALL,
+ )
# Then convert any remaining HTML to markdown
text = self._convert_html_to_markdown(text)
+ text = _restore_blocks(text, macro_headings, "JIRAMACRO")
return text.strip()
@@ -208,7 +381,13 @@ def _process_smart_links(self, text: str) -> str:
return text
- def jira_to_markdown(self, input_text: str) -> str:
+ def jira_to_markdown(
+ self,
+ input_text: str,
+ *,
+ _keep_macro_marks: bool = False,
+ _macro_heading_mark: str | None = None,
+ ) -> str:
"""
Convert Jira markup to Markdown format.
@@ -225,6 +404,11 @@ def jira_to_markdown(self, input_text: str) -> str:
return input_text
output = input_text
+ macro_heading_mark = None
+ if _keep_macro_marks:
+ macro_heading_mark = _macro_heading_mark or _new_macro_heading_mark(
+ input_text
+ )
# Protect code/noformat/inline-code blocks from downstream
# transformations by replacing them with placeholders.
@@ -336,6 +520,13 @@ def _jira_code_to_md(match: re.Match[str]) -> str:
flags=re.MULTILINE,
)
+ # Admonition and expand blocks -> bold labelled heading. A stack-based
+ # inside-out scan (see _convert_macro_blocks) handles nested same-type
+ # blocks — {expand:Outer}{expand:Inner}..{expand}..{expand} or
+ # {info}..{info}..{info} — so they close at the correct boundary instead
+ # of at the first inner tag, matching Atlassian's nested-macro semantics.
+ output = _convert_macro_blocks(output, macro_heading_mark)
+
# Images with alt text
output = re.sub(
r"!([^|\n\s]+)\|([^\n!]*)alt=([^\n!\,]+?)"
diff --git a/tests/unit/preprocessing/test_preprocessing.py b/tests/unit/preprocessing/test_preprocessing.py
index 7d2e9032e..541742f5a 100644
--- a/tests/unit/preprocessing/test_preprocessing.py
+++ b/tests/unit/preprocessing/test_preprocessing.py
@@ -1420,6 +1420,333 @@ def test_bare_link_without_panel(self, preprocessor):
assert "https://example.com" in result, f"URL dropped: {result}"
+# {info}/{note}/{warning}/{tip} admonitions and {expand} collapsible toggles
+
+
+class TestAdmonitionAndExpandBlocks:
+ """Tests for read-only {info}/{note}/{warning}/{tip} and {expand} conversion."""
+
+ @pytest.fixture
+ def preprocessor(self):
+ return JiraPreprocessor(base_url="https://example.atlassian.net")
+
+ @pytest.mark.parametrize(
+ "test_id, input_text, expected_present, expected_absent",
+ [
+ pytest.param(
+ "info-with-title",
+ "{info:title=AUTO-GENERATED METADATA}do not edit{info}",
+ ["**ℹ️ Info: AUTO-GENERATED METADATA**", "do not edit"],
+ ["{info"],
+ id="info-with-title",
+ ),
+ pytest.param(
+ "info-no-title",
+ "{info}some content{info}",
+ ["**ℹ️ Info**", "some content"],
+ ["{info"],
+ id="info-no-title",
+ ),
+ pytest.param(
+ "note-block",
+ "{note}heads up{note}",
+ ["**📝 Note**", "heads up"],
+ ["{note"],
+ id="note-block",
+ ),
+ pytest.param(
+ "warning-block",
+ "{warning}be careful{warning}",
+ ["**⚠️ Warning**", "be careful"],
+ ["{warning"],
+ id="warning-block",
+ ),
+ pytest.param(
+ "tip-block",
+ "{tip}pro tip{tip}",
+ ["**💡 Tip**", "pro tip"],
+ ["{tip"],
+ id="tip-block",
+ ),
+ pytest.param(
+ "info-multiline",
+ "{info:title=Notes}line one\nline two{info}",
+ ["**ℹ️ Info: Notes**", "line one", "line two"],
+ ["{info"],
+ id="info-multiline",
+ ),
+ pytest.param(
+ "adjacent-admonitions-distinct",
+ "{info}a{info}\n{warning}b{warning}",
+ ["**ℹ️ Info**", "**⚠️ Warning**", "a", "b"],
+ ["{info", "{warning"],
+ id="adjacent-admonitions-distinct",
+ ),
+ pytest.param(
+ "expand-with-title",
+ "{expand:Security MetaData}secret stuff{expand}",
+ ["**▸ Expand: Security MetaData**", "secret stuff"],
+ ["{expand", "md->html->md).
+
+ clean_jira_text runs jira_to_markdown then _convert_html_to_markdown;
+ plain bold markdown passes through the markdownify step unchanged.
+ """
+ result = preprocessor.clean_jira_text(
+ "{info:title=AUTO-GENERATED METADATA}do not edit{info}"
+ )
+ assert "ℹ️ Info: AUTO-GENERATED METADATA" in result, result
+ assert "do not edit" in result, result
+ assert "{info" not in result, result
+
+ @pytest.mark.parametrize(
+ "input_text, expected_summaries",
+ [
+ pytest.param(
+ "{expand:Outer}{expand:Inner}inside{expand}after{expand}",
+ ["▸ Expand: Outer", "▸ Expand: Inner"],
+ id="same-type-expand",
+ ),
+ pytest.param(
+ "{info:title=A}{info:title=B}deep{info}tail{info}",
+ ["ℹ️ Info: A", "ℹ️ Info: B"],
+ id="same-type-info",
+ ),
+ pytest.param(
+ "{expand:L1}{expand:L2}{info}x{info}mid{expand}end{expand}",
+ ["▸ Expand: L1", "▸ Expand: L2", "ℹ️ Info"],
+ id="three-level-mixed",
+ ),
+ pytest.param(
+ "{expand:More}{info:title=Context}inside{info}{expand}",
+ ["▸ Expand: More", "ℹ️ Info: Context"],
+ id="cross-type-nesting",
+ ),
+ ],
+ )
+ def test_nested_macros_convert_without_leaking_tags(
+ self, preprocessor, input_text: str, expected_summaries: list[str]
+ ):
+ """Nested (including same-type) macros fully convert with no raw tags."""
+ result = preprocessor.jira_to_markdown(input_text)
+ for summary in expected_summaries:
+ assert f"**{summary}**" in result, f"missing '{summary}' in: {result}"
+ for tag in ("{info", "{note", "{warning", "{tip", "{expand"):
+ assert tag not in result, f"raw '{tag}' leaked: {result}"
+
+ @pytest.mark.parametrize(
+ "input_text, expected",
+ [
+ pytest.param(
+ "{expand:Outer}before{info:title=Inner}inside{info}after{expand}",
+ "**▸ Expand: Outer**\nbefore\n**ℹ️ Info: Inner**\ninside\nafter",
+ id="surrounding-prose",
+ ),
+ pytest.param(
+ "{expand:Outer}{info:title=First}one{info}"
+ "{warning}two{warning}{expand}",
+ "**▸ Expand: Outer**\n**ℹ️ Info: First**\none\n\n**⚠️ Warning**\ntwo",
+ id="adjacent-nested-blocks",
+ ),
+ ],
+ )
+ def test_nested_macro_block_boundaries_survive_clean_path(
+ self, preprocessor, input_text: str, expected: str
+ ):
+ """The full Jira fetch path preserves nested Markdown block separators."""
+ assert preprocessor.clean_jira_text(input_text) == expected
+
+ def test_multiline_admonition_body_preserved(self, preprocessor):
+ """A multi-paragraph body is kept intact under the heading."""
+ result = preprocessor.jira_to_markdown(
+ "{info:title=Context}first paragraph\n\nsecond paragraph{info}"
+ )
+ assert "**ℹ️ Info: Context**" in result, result
+ assert "first paragraph" in result and "second paragraph" in result, result
+ assert "{info" not in result, result
+
+ @pytest.mark.parametrize(
+ ("jira_markup", "expected"),
+ [
+ (
+ "{info:title=Use tag}body
{info}",
+ "**ℹ️ Info: Use <summary> tag**\n\nbody",
+ ),
+ (
+ "{info:title=bold}body
{info}",
+ "**ℹ️ Info: <b>bold</b>**\n\nbody",
+ ),
+ (
+ "{warning:title=2 < 3 and 4 > 1}x
{warning}",
+ "**⚠️ Warning: 2 < 3 and 4 > 1**\n\nx",
+ ),
+ (
+ "{expand:a < b > c}body
{expand}",
+ "**▸ Expand: a < b > c**\n\nbody",
+ ),
+ ],
+ )
+ def test_html_looking_title_survives_clean_path(
+ self, preprocessor, jira_markup, expected
+ ):
+ """Angle brackets / HTML in a title survive the production read path.
+
+ clean_jira_text runs jira_to_markdown then _convert_html_to_markdown;
+ the generated heading is protected while markdownify handles unrelated
+ HTML, so both the title text and its Markdown formatting are preserved.
+ """
+ assert preprocessor.clean_jira_text(jira_markup) == expected
+
+ @pytest.mark.parametrize(
+ ("label", "html_markup", "expected"),
+ [
+ ("▸ Expand", "bold", "**bold**"),
+ (
+ "▸ Expand",
+ 'link',
+ "[link](https://example.com)",
+ ),
+ ("▸ Expand", "", None),
+ ("ℹ️ Info", "bold", "**bold**"),
+ (
+ "ℹ️ Info",
+ 'link',
+ "[link](https://example.com)",
+ ),
+ ("ℹ️ Info", "", None),
+ ],
+ )
+ def test_user_authored_matching_bold_line_uses_html_conversion(
+ self, preprocessor, label, html_markup, expected
+ ):
+ """Matching user text isn't protected as a generated macro heading.
+
+ Only headings emitted by ``_convert_macro_blocks`` are protected from
+ the HTML pass. Ordinary bold Jira text with the same visible prefix
+ must still convert or remove nested HTML through that existing path.
+ """
+ result = preprocessor.clean_jira_text(f"*{label}: {html_markup}*body
")
+
+ assert "" not in result and "" not in result, result
+ assert "" not in result, result
+ assert "" not in result, result
+ if expected is None:
+ assert "alert(1)" not in result, result
+ else:
+ assert expected in result, result
+ assert "body" in result, result
+
+ def test_generated_heading_survives_body_html(self, preprocessor):
+ """A genuine macro heading keeps its bold markers when the body has HTML."""
+ result = preprocessor.clean_jira_text(
+ "{info:title=Ctx}see this and more{info}"
+ )
+ assert "**ℹ️ Info: Ctx**" in result, result
+ assert "**this**" in result, result # body HTML converted to Markdown
+ assert "" not in result, result
+
+ def test_forged_macro_heading_marker_does_not_bypass_html_conversion(
+ self, preprocessor
+ ):
+ """A user-supplied legacy marker cannot protect script HTML."""
+ forged_marker = "\x00JMH\x00"
+ result = preprocessor.clean_jira_text(
+ f"{forged_marker}**▸ Expand: **"
+ f"{forged_marker}body
"
+ )
+
+ assert "" not in result, result
+ assert "alert(1)" not in result, result
+ assert "body" in result, result
+
+ @pytest.mark.parametrize(
+ "jira_markup, expected_heading",
+ [
+ (
+ "{info:title=First line\nSecond line}body{info}",
+ "**ℹ️ Info: First line Second line**",
+ ),
+ (
+ "{expand:title=First line\nSecond line}body{expand}",
+ "**▸ Expand: First line Second line**",
+ ),
+ ],
+ )
+ def test_multiline_macro_title_is_normalized_and_marker_free(
+ self, preprocessor, jira_markup, expected_heading
+ ):
+ """Multiline titles don't leak protection markers or break Markdown."""
+ result = preprocessor.clean_jira_text(jira_markup)
+
+ assert result == f"{expected_heading}\nbody", result
+ assert "\x00" not in result, result
+
+ def test_read_only_leaves_fenced_code_write_back_unchanged(self, preprocessor):
+ """Read-only scope must not alter Markdown->Jira fenced-code behaviour."""
+ assert preprocessor.markdown_to_jira("```\nline\n```") == "{code}line\n{code}"
+
+
# Code block placeholder protection tests