diff --git a/server.py b/server.py index b605551..419a2d3 100644 --- a/server.py +++ b/server.py @@ -365,12 +365,415 @@ 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 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. + """ + import re + + lines = content.split("\n") + output = [] + i = 0 + 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] + color = "#07c160" if level <= 2 else "#333" + output.append( + f'

{text}

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

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

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

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

' + ) + 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 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, + suggest_hashtags: bool = True, +) -> str: + """Split and format content into an X (Twitter) thread, with hashtag suggestions. + + 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 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() + + # 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 = [], "" + 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: + 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) + + # 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) + + +@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_html( + title: str, + subtitle: str = "", + author: str = "", + bg_color: str = "#07c160", + text_color: str = "#ffffff", + width: int = 900, + height: int = 383, +) -> str: + """Generate a standalone HTML file for an article cover image. + + 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 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). + """ + 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() +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 with custom colors. + + 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 (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) + + 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")