From f8a4b477b9b8ef0136f26f8f5ee27d3bab843ecb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 15:30:58 +0000 Subject: [PATCH 1/4] Add article-tools: WeChat/X formatting and cover generation MCP tools Integrates functionality from eternityspring/article-tools (cover making, WeChat public account and X typesetting tools) as MCP tools: - get_article_tool_url: returns the GitHub Pages URL for each web tool - format_markdown_for_wechat: converts markdown to WeChat-compatible HTML - format_markdown_for_x: splits content into a numbered X thread - generate_article_cover_config: produces cover image JSON configuration https://claude.ai/code/session_01DyiXD3knKRaa27Zr2XDamh --- server.py | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 162 insertions(+), 5 deletions(-) diff --git a/server.py b/server.py index b605551..ef4e5aa 100644 --- a/server.py +++ b/server.py @@ -365,12 +365,169 @@ async def get_sec_filings( # Stringify the SEC filings return json.dumps(filings, indent=2) +ARTICLE_TOOLS_BASE = "https://eternityspring.github.io/article-tools" + + +@mcp.tool() +def get_article_tool_url(tool: str) -> str: + """Get the URL for an article formatting tool. + + Args: + tool: The tool name. One of: 'cover', 'md-to-wechat', 'md-to-x', 'qrcode', 'index'. + """ + valid_tools = {"cover", "md-to-wechat", "md-to-x", "qrcode", "index"} + if tool not in valid_tools: + return f"Unknown tool '{tool}'. Valid options: {', '.join(sorted(valid_tools))}" + if tool == "index": + return ARTICLE_TOOLS_BASE + "/" + return f"{ARTICLE_TOOLS_BASE}/{tool}.html" + + +@mcp.tool() +def format_markdown_for_wechat(content: str) -> str: + """Convert markdown content to WeChat public account compatible HTML format. + + Args: + content: Markdown text to convert. + """ + import re + + lines = content.split("\n") + output = [] + i = 0 + while i < len(lines): + line = lines[i] + + # Headers + m = re.match(r"^(#{1,6})\s+(.*)", line) + if m: + level = len(m.group(1)) + text = m.group(2).strip() + size = {1: "24px", 2: "20px", 3: "18px", 4: "16px", 5: "15px", 6: "14px"}[level] + output.append(f'

{text}

') + i += 1 + continue + + # Blank line → paragraph break + if line.strip() == "": + i += 1 + continue + + # Unordered list item + m = re.match(r"^[-*+]\s+(.*)", line) + if m: + output.append(f'

• {_inline_md(m.group(1))}

') + i += 1 + continue + + # Ordered list item + m = re.match(r"^\d+\.\s+(.*)", line) + if m: + output.append(f'

{_inline_md(m.group(0))}

') + i += 1 + continue + + # Regular paragraph + output.append(f'

{_inline_md(line)}

') + i += 1 + + return "\n".join(output) + + +def _inline_md(text: str) -> str: + """Convert inline markdown (bold, italic, code, links) to HTML.""" + import re + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text = re.sub(r"__(.+?)__", r"\1", text) + text = re.sub(r"\*(.+?)\*", r"\1", text) + text = re.sub(r"_(.+?)_", r"\1", text) + text = re.sub(r"`(.+?)`", r'\1', text) + text = re.sub(r"\[(.+?)\]\((.+?)\)", r'\1', text) + return text + + +@mcp.tool() +def format_markdown_for_x(content: str, max_chars: int = 270) -> str: + """Split and format content into an X (Twitter) thread. + + Each post in the thread is kept under max_chars. The thread is returned as + numbered posts separated by '---'. + + Args: + content: The text or markdown content to format. + max_chars: Maximum characters per post (default 270, leaving room for numbering). + """ + import re + + # Strip markdown syntax to plain text + text = re.sub(r"#{1,6}\s+", "", content) + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text = re.sub(r"__(.+?)__", r"\1", text) + text = re.sub(r"\*(.+?)\*", r"\1", text) + text = re.sub(r"_(.+?)_", r"\1", text) + text = re.sub(r"`(.+?)`", r"\1", text) + text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text) + text = re.sub(r"\n{3,}", "\n\n", text).strip() + + # Split into sentences + sentences = re.split(r"(?<=[。!?.!?])\s*", text) + sentences = [s.strip() for s in sentences if s.strip()] + + posts = [] + current = "" + for sentence in sentences: + if len(current) + len(sentence) + 1 <= max_chars: + current = (current + " " + sentence).strip() + else: + if current: + posts.append(current) + if len(sentence) > max_chars: + # Hard-split long sentence + for j in range(0, len(sentence), max_chars): + posts.append(sentence[j:j + max_chars]) + current = "" + else: + current = sentence + if current: + posts.append(current) + + total = len(posts) + numbered = [f"[{idx + 1}/{total}] {post}" for idx, post in enumerate(posts)] + return "\n---\n".join(numbered) + + +@mcp.tool() +def generate_article_cover_config( + title: str, + subtitle: str = "", + author: str = "", + bg_color: str = "#ffffff", + text_color: str = "#333333", +) -> str: + """Generate a JSON configuration for an article cover image. + + Returns a JSON object that can be used with the article-tools cover generator + at https://eternityspring.github.io/article-tools/cover.html + + Args: + title: Main article title. + subtitle: Optional subtitle or description. + author: Author name. + bg_color: Background color hex code (default #ffffff). + text_color: Text color hex code (default #333333). + """ + config = { + "title": title, + "subtitle": subtitle, + "author": author, + "bgColor": bg_color, + "textColor": text_color, + "toolUrl": f"{ARTICLE_TOOLS_BASE}/cover.html", + } + return json.dumps(config, ensure_ascii=False, indent=2) + + if __name__ == "__main__": - # Log server startup logger.info("Starting Financial Datasets MCP Server...") - - # Initialize and run the server mcp.run(transport="stdio") - - # This line won't be reached during normal operation logger.info("Server stopped") From 6e446cebe8d52d11cdcc1ffeac6526dbdad5ab21 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 02:45:08 +0000 Subject: [PATCH 2/4] Add format_markdown_for_threads tool for Threads format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threads posts support up to 500 characters; non-final posts end with 👇 to signal thread continuation, matching Threads platform convention. https://claude.ai/code/session_01DyiXD3knKRaa27Zr2XDamh --- server.py | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/server.py b/server.py index ef4e5aa..feada2c 100644 --- a/server.py +++ b/server.py @@ -496,6 +496,61 @@ def format_markdown_for_x(content: str, max_chars: int = 270) -> str: return "\n---\n".join(numbered) +@mcp.tool() +def format_markdown_for_threads(content: str, max_chars: int = 500) -> str: + """Split and format content into a Threads thread. + + Each post is kept under max_chars (Threads limit is 500). Posts are + separated by a blank line. Non-final posts end with 👇 to signal continuation. + + Args: + content: The text or markdown content to format. + max_chars: Maximum characters per post (default 500). + """ + import re + + # Strip markdown to plain text + text = re.sub(r"#{1,6}\s+", "", content) + text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) + text = re.sub(r"__(.+?)__", r"\1", text) + text = re.sub(r"\*(.+?)\*", r"\1", text) + text = re.sub(r"_(.+?)_", r"\1", text) + text = re.sub(r"`(.+?)`", r"\1", text) + text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text) + text = re.sub(r"\n{3,}", "\n\n", text).strip() + + # Split into sentences + sentences = re.split(r"(?<=[。!?.!?])\s*", text) + sentences = [s.strip() for s in sentences if s.strip()] + + # Pack sentences into posts within max_chars (reserve 2 chars for 👇) + limit = max_chars - 2 + posts, current = [], "" + for sentence in sentences: + if len(current) + len(sentence) + 1 <= limit: + current = (current + "\n" + sentence).strip() + else: + if current: + posts.append(current) + if len(sentence) > limit: + for j in range(0, len(sentence), limit): + posts.append(sentence[j:j + limit]) + current = "" + else: + current = sentence + if current: + posts.append(current) + + result = [] + for i, post in enumerate(posts): + if i < len(posts) - 1: + result.append(post + "\n👇") + else: + result.append(post) + + return "\n\n".join(result) + + @mcp.tool() def generate_article_cover_config( title: str, From a49315e74c29e9d4fbeb67402b255c4afdad1a78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 02:46:22 +0000 Subject: [PATCH 3/4] Add generate_qrcode tool via api.qrserver.com MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates a QR code image URL for any text or URL. Supports png/svg formats and configurable size (100–1000 px), no extra dependencies needed. https://claude.ai/code/session_01DyiXD3knKRaa27Zr2XDamh --- server.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/server.py b/server.py index feada2c..3e54f07 100644 --- a/server.py +++ b/server.py @@ -582,6 +582,41 @@ def generate_article_cover_config( return json.dumps(config, ensure_ascii=False, indent=2) +@mcp.tool() +async def generate_qrcode( + content: str, + size: int = 300, + format: str = "png", +) -> str: + """Generate a QR code for the given text or URL. + + Returns a direct image URL that can be opened in a browser or embedded in + articles. Uses the free api.qrserver.com service. + + Args: + content: Text or URL to encode in the QR code. + size: Image size in pixels (width × height), default 300. + format: Image format, either 'png' or 'svg' (default 'png'). + """ + import urllib.parse + + if format not in ("png", "svg"): + format = "png" + size = max(100, min(1000, size)) + encoded = urllib.parse.quote(content) + url = ( + f"https://api.qrserver.com/v1/create-qr-code/" + f"?data={encoded}&size={size}x{size}&format={format}" + ) + result = { + "qrcode_url": url, + "content": content, + "size": size, + "format": format, + } + return json.dumps(result, ensure_ascii=False, indent=2) + + if __name__ == "__main__": logger.info("Starting Financial Datasets MCP Server...") mcp.run(transport="stdio") From 0fc50e37921424f54061eff045dcaef8e6d09120 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Apr 2026 02:54:49 +0000 Subject: [PATCH 4/4] Implement remaining article-tools features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - format_markdown_for_wechat: add table, fenced code block, blockquote, horizontal rule support; WeChat green accent color (#07c160) - format_markdown_for_x: add hashtag extraction and suggestion on last post - generate_article_cover_html: output standalone HTML/CSS cover page (900×383 px WeChat size) replacing JSON-only config - generate_qrcode: add fg_color / bg_color parameters via api.qrserver.com https://claude.ai/code/session_01DyiXD3knKRaa27Zr2XDamh --- server.py | 244 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 200 insertions(+), 44 deletions(-) diff --git a/server.py b/server.py index 3e54f07..419a2d3 100644 --- a/server.py +++ b/server.py @@ -385,7 +385,10 @@ def get_article_tool_url(tool: str) -> str: @mcp.tool() def format_markdown_for_wechat(content: str) -> str: - """Convert markdown content to WeChat public account compatible HTML format. + """Convert markdown to WeChat public account compatible HTML. + + Supports headers, bold/italic, inline code, links, unordered/ordered lists, + blockquotes, fenced code blocks, horizontal rules, and tables. Args: content: Markdown text to convert. @@ -398,68 +401,140 @@ def format_markdown_for_wechat(content: str) -> str: while i < len(lines): line = lines[i] + # Fenced code block + if re.match(r"^```", line): + lang = line[3:].strip() + code_lines = [] + i += 1 + while i < len(lines) and not re.match(r"^```", lines[i]): + code_lines.append(lines[i]) + i += 1 + code = "\n".join(code_lines) + output.append( + f'
'
+                f'{code}
' + ) + i += 1 + continue + + # Table (detect by | separator) + if "|" in line and re.match(r"^\|", line.strip()): + table_lines = [] + while i < len(lines) and "|" in lines[i]: + table_lines.append(lines[i]) + i += 1 + # Remove separator row (---|---) + rows = [r for r in table_lines if not re.match(r"^[\s|:-]+$", r)] + html = '' + for row_idx, row in enumerate(rows): + cells = [c.strip() for c in row.strip().strip("|").split("|")] + tag = "th" if row_idx == 0 else "td" + style = ( + 'style="border:1px solid #ddd;padding:6px 10px;' + + ("background:#f0f0f0;font-weight:bold;" if row_idx == 0 else "") + + '"' + ) + html += "" + "".join(f"<{tag} {style}>{_inline_md(c)}" for c in cells) + "" + html += "
" + output.append(html) + continue + + # Horizontal rule + if re.match(r"^(-{3,}|\*{3,}|_{3,})$", line.strip()): + output.append('
') + i += 1 + continue + + # Blockquote + if line.startswith(">"): + quote_lines = [] + while i < len(lines) and lines[i].startswith(">"): + quote_lines.append(lines[i].lstrip("> ")) + i += 1 + inner = " ".join(quote_lines) + output.append( + f'
' + f'{_inline_md(inner)}
' + ) + continue + # Headers m = re.match(r"^(#{1,6})\s+(.*)", line) if m: level = len(m.group(1)) text = m.group(2).strip() size = {1: "24px", 2: "20px", 3: "18px", 4: "16px", 5: "15px", 6: "14px"}[level] - output.append(f'

{text}

') + color = "#07c160" if level <= 2 else "#333" + output.append( + f'

{text}

' + ) i += 1 continue - # Blank line → paragraph break + # Blank line if line.strip() == "": i += 1 continue - # Unordered list item + # Unordered list m = re.match(r"^[-*+]\s+(.*)", line) if m: - output.append(f'

• {_inline_md(m.group(1))}

') + output.append(f'

• {_inline_md(m.group(1))}

') i += 1 continue - # Ordered list item - m = re.match(r"^\d+\.\s+(.*)", line) + # Ordered list + m = re.match(r"^(\d+)\.\s+(.*)", line) if m: - output.append(f'

{_inline_md(m.group(0))}

') + output.append( + f'

' + f'{m.group(1)}. {_inline_md(m.group(2))}

' + ) i += 1 continue # Regular paragraph - output.append(f'

{_inline_md(line)}

') + output.append(f'

{_inline_md(line)}

') i += 1 return "\n".join(output) def _inline_md(text: str) -> str: - """Convert inline markdown (bold, italic, code, links) to HTML.""" + """Convert inline markdown to HTML.""" import re text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) text = re.sub(r"__(.+?)__", r"\1", text) text = re.sub(r"\*(.+?)\*", r"\1", text) text = re.sub(r"_(.+?)_", r"\1", text) - text = re.sub(r"`(.+?)`", r'\1', text) - text = re.sub(r"\[(.+?)\]\((.+?)\)", r'\1', text) + text = re.sub(r"`(.+?)`", r'\1', text) + text = re.sub(r"\[(.+?)\]\((.+?)\)", r'\1', text) return text @mcp.tool() -def format_markdown_for_x(content: str, max_chars: int = 270) -> str: - """Split and format content into an X (Twitter) thread. +def format_markdown_for_x( + content: str, + max_chars: int = 270, + suggest_hashtags: bool = True, +) -> str: + """Split and format content into an X (Twitter) thread, with hashtag suggestions. - Each post in the thread is kept under max_chars. The thread is returned as - numbered posts separated by '---'. + Each post is kept under max_chars. The last post includes suggested hashtags + extracted from the content's keywords. Args: content: The text or markdown content to format. max_chars: Maximum characters per post (default 270, leaving room for numbering). + suggest_hashtags: Append suggested hashtags to the last post (default True). """ import re - # Strip markdown syntax to plain text + # Strip markdown to plain text text = re.sub(r"#{1,6}\s+", "", content) text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) text = re.sub(r"__(.+?)__", r"\1", text) @@ -469,12 +544,17 @@ def format_markdown_for_x(content: str, max_chars: int = 270) -> str: text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text) text = re.sub(r"\n{3,}", "\n\n", text).strip() + # Extract hashtag candidates from existing #tags and capitalised words + raw_tags = re.findall(r"#(\w+)", content) + cap_words = re.findall(r"\b([A-Z][a-zA-Z]{3,})\b", content) + candidates = list(dict.fromkeys(raw_tags + cap_words))[:5] + hashtags = " ".join(f"#{t}" for t in candidates) + # Split into sentences sentences = re.split(r"(?<=[。!?.!?])\s*", text) sentences = [s.strip() for s in sentences if s.strip()] - posts = [] - current = "" + posts, current = [], "" for sentence in sentences: if len(current) + len(sentence) + 1 <= max_chars: current = (current + " " + sentence).strip() @@ -482,7 +562,6 @@ def format_markdown_for_x(content: str, max_chars: int = 270) -> str: if current: posts.append(current) if len(sentence) > max_chars: - # Hard-split long sentence for j in range(0, len(sentence), max_chars): posts.append(sentence[j:j + max_chars]) current = "" @@ -491,6 +570,13 @@ def format_markdown_for_x(content: str, max_chars: int = 270) -> str: if current: posts.append(current) + # Append hashtags to last post if they fit + if suggest_hashtags and hashtags and posts: + last = posts[-1] + candidate_last = last + "\n" + hashtags + if len(candidate_last) <= max_chars + 30: + posts[-1] = candidate_last + total = len(posts) numbered = [f"[{idx + 1}/{total}] {post}" for idx, post in enumerate(posts)] return "\n---\n".join(numbered) @@ -552,34 +638,95 @@ def format_markdown_for_threads(content: str, max_chars: int = 500) -> str: @mcp.tool() -def generate_article_cover_config( +def generate_article_cover_html( title: str, subtitle: str = "", author: str = "", - bg_color: str = "#ffffff", - text_color: str = "#333333", + bg_color: str = "#07c160", + text_color: str = "#ffffff", + width: int = 900, + height: int = 383, ) -> str: - """Generate a JSON configuration for an article cover image. + """Generate a standalone HTML file for an article cover image. - Returns a JSON object that can be used with the article-tools cover generator - at https://eternityspring.github.io/article-tools/cover.html + The returned HTML can be saved as a .html file and opened in a browser + to screenshot/export as the article cover. Default dimensions match + WeChat public account cover size (900×383 px). Args: title: Main article title. - subtitle: Optional subtitle or description. - author: Author name. - bg_color: Background color hex code (default #ffffff). - text_color: Text color hex code (default #333333). + subtitle: Optional subtitle or tagline. + author: Author name shown at bottom-right. + bg_color: Background color (default WeChat green #07c160). + text_color: Text color (default #ffffff). + width: Cover width in pixels (default 900). + height: Cover height in pixels (default 383). """ - config = { - "title": title, - "subtitle": subtitle, - "author": author, - "bgColor": bg_color, - "textColor": text_color, - "toolUrl": f"{ARTICLE_TOOLS_BASE}/cover.html", - } - return json.dumps(config, ensure_ascii=False, indent=2) + subtitle_html = ( + f'
{subtitle}
' if subtitle else "" + ) + author_html = ( + f'
— {author}
' if author else "" + ) + html = f""" + + + + + + +
+
{title}
+ {subtitle_html} + {author_html} +
+ +""" + return html @mcp.tool() @@ -587,32 +734,41 @@ async def generate_qrcode( content: str, size: int = 300, format: str = "png", + fg_color: str = "000000", + bg_color: str = "ffffff", ) -> str: - """Generate a QR code for the given text or URL. + """Generate a QR code for the given text or URL with custom colors. - Returns a direct image URL that can be opened in a browser or embedded in - articles. Uses the free api.qrserver.com service. + Returns a direct image URL using the free api.qrserver.com service. + The URL can be opened in a browser or embedded directly in articles. Args: content: Text or URL to encode in the QR code. - size: Image size in pixels (width × height), default 300. - format: Image format, either 'png' or 'svg' (default 'png'). + size: Image size in pixels (default 300, range 100–1000). + format: Image format, 'png' or 'svg' (default 'png'). + fg_color: Foreground (module) color as hex without # (default 000000 = black). + bg_color: Background color as hex without # (default ffffff = white). """ import urllib.parse if format not in ("png", "svg"): format = "png" size = max(100, min(1000, size)) + fg_color = fg_color.lstrip("#") + bg_color = bg_color.lstrip("#") encoded = urllib.parse.quote(content) url = ( f"https://api.qrserver.com/v1/create-qr-code/" f"?data={encoded}&size={size}x{size}&format={format}" + f"&color={fg_color}&bgcolor={bg_color}" ) result = { "qrcode_url": url, "content": content, "size": size, "format": format, + "fg_color": f"#{fg_color}", + "bg_color": f"#{bg_color}", } return json.dumps(result, ensure_ascii=False, indent=2)