|
24 | 24 | from __future__ import annotations |
25 | 25 |
|
26 | 26 | import os |
| 27 | +import re |
27 | 28 | from datetime import datetime, timedelta |
28 | 29 | from typing import Any |
29 | 30 |
|
| 31 | +import httpx |
30 | 32 | from mcp.server.fastmcp import FastMCP |
31 | 33 |
|
32 | 34 | from late import Late, MediaType, PostStatus |
33 | 35 |
|
34 | 36 | from .tool_definitions import use_tool_def |
35 | 37 |
|
| 38 | +# Cache for documentation content |
| 39 | +_docs_cache: dict[str, tuple[str, datetime]] = {} |
| 40 | +_DOCS_URL = "https://docs.getlate.dev/llms-full.txt" |
| 41 | +_CACHE_TTL_HOURS = 24 |
| 42 | + |
36 | 43 | # Initialize MCP server |
37 | 44 | mcp = FastMCP( |
38 | 45 | "Late", |
|
44 | 51 | - profiles_* : Manage profiles (groups of accounts) |
45 | 52 | - posts_* : Create, list, update, delete posts |
46 | 53 | - media_* : Upload images and videos |
| 54 | +- docs_* : Search Late API documentation |
47 | 55 | """, |
48 | 56 | ) |
49 | 57 |
|
@@ -635,6 +643,114 @@ def media_check_upload_status(token: str) -> str: |
635 | 643 | return f"❌ Failed to check upload status: {e}" |
636 | 644 |
|
637 | 645 |
|
| 646 | +# ============================================================================ |
| 647 | +# DOCS |
| 648 | +# ============================================================================ |
| 649 | + |
| 650 | + |
| 651 | +def _get_docs_content() -> str: |
| 652 | + """Fetch and cache documentation content.""" |
| 653 | + cache_key = "docs" |
| 654 | + |
| 655 | + # Check cache |
| 656 | + if cache_key in _docs_cache: |
| 657 | + content, cached_at = _docs_cache[cache_key] |
| 658 | + if datetime.now() - cached_at < timedelta(hours=_CACHE_TTL_HOURS): |
| 659 | + return content |
| 660 | + |
| 661 | + # Fetch fresh content |
| 662 | + try: |
| 663 | + response = httpx.get(_DOCS_URL, timeout=30.0) |
| 664 | + response.raise_for_status() |
| 665 | + content = response.text |
| 666 | + _docs_cache[cache_key] = (content, datetime.now()) |
| 667 | + return content |
| 668 | + except Exception as e: |
| 669 | + # Return cached content if available, even if expired |
| 670 | + if cache_key in _docs_cache: |
| 671 | + return _docs_cache[cache_key][0] |
| 672 | + raise RuntimeError(f"Failed to fetch documentation: {e}") from e |
| 673 | + |
| 674 | + |
| 675 | +def _search_docs(content: str, query: str, max_results: int = 5) -> list[dict[str, str]]: |
| 676 | + """Search documentation content for relevant sections.""" |
| 677 | + results: list[dict[str, str]] = [] |
| 678 | + query_lower = query.lower() |
| 679 | + query_terms = query_lower.split() |
| 680 | + |
| 681 | + # Split content into sections (by markdown headers) |
| 682 | + sections = re.split(r'\n(?=#{1,3} )', content) |
| 683 | + |
| 684 | + scored_sections: list[tuple[int, str, str]] = [] |
| 685 | + |
| 686 | + for section in sections: |
| 687 | + if not section.strip(): |
| 688 | + continue |
| 689 | + |
| 690 | + section_lower = section.lower() |
| 691 | + |
| 692 | + # Calculate relevance score |
| 693 | + score = 0 |
| 694 | + |
| 695 | + # Exact phrase match (highest priority) |
| 696 | + if query_lower in section_lower: |
| 697 | + score += 100 |
| 698 | + |
| 699 | + # Individual term matches |
| 700 | + for term in query_terms: |
| 701 | + if term in section_lower: |
| 702 | + score += 10 |
| 703 | + # Bonus for term in header |
| 704 | + first_line = section.split('\n')[0].lower() |
| 705 | + if term in first_line: |
| 706 | + score += 20 |
| 707 | + |
| 708 | + if score > 0: |
| 709 | + # Extract title from first line |
| 710 | + lines = section.strip().split('\n') |
| 711 | + title = lines[0].lstrip('#').strip() if lines else "Untitled" |
| 712 | + scored_sections.append((score, title, section.strip())) |
| 713 | + |
| 714 | + # Sort by score and take top results |
| 715 | + scored_sections.sort(key=lambda x: x[0], reverse=True) |
| 716 | + |
| 717 | + for score, title, section_text in scored_sections[:max_results]: |
| 718 | + # Truncate long sections |
| 719 | + if len(section_text) > 1500: |
| 720 | + section_text = section_text[:1500] + "\n...(truncated)" |
| 721 | + |
| 722 | + results.append({ |
| 723 | + "title": title, |
| 724 | + "content": section_text, |
| 725 | + "relevance": str(score), |
| 726 | + }) |
| 727 | + |
| 728 | + return results |
| 729 | + |
| 730 | + |
| 731 | +@mcp.tool() |
| 732 | +@use_tool_def("docs_search") |
| 733 | +def docs_search(query: str) -> str: |
| 734 | + try: |
| 735 | + content = _get_docs_content() |
| 736 | + results = _search_docs(content, query) |
| 737 | + |
| 738 | + if not results: |
| 739 | + return f"No documentation found for '{query}'. Try different search terms." |
| 740 | + |
| 741 | + lines = [f"Found {len(results)} relevant section(s) for '{query}':\n"] |
| 742 | + |
| 743 | + for i, result in enumerate(results, 1): |
| 744 | + lines.append(f"--- Result {i}: {result['title']} ---") |
| 745 | + lines.append(result["content"]) |
| 746 | + lines.append("") |
| 747 | + |
| 748 | + return "\n".join(lines) |
| 749 | + |
| 750 | + except Exception as e: |
| 751 | + return f"❌ Failed to search documentation: {e}" |
| 752 | + |
| 753 | + |
638 | 754 | # ============================================================================ |
639 | 755 | # MAIN |
640 | 756 | # ============================================================================ |
|
0 commit comments