From a5690222e13506354c2b97caa658d97ba3e68ed5 Mon Sep 17 00:00:00 2001 From: Late Date: Thu, 8 Jan 2026 11:19:52 +0100 Subject: [PATCH 1/2] Add docs search tool (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Update repository URLs to getlatedev/late-python-sdk * fix: Resolve lint errors and update MCP test imports * fix: Resolve mypy errors and add params to HTTP methods * ci: Improve release workflow with better visibility and PyPI check - Add release summary in GitHub Actions - Check if version exists on PyPI before publishing - Show clear notices for skip/release decisions - Bump version to 1.0.1 * ci: Add release preview comment on PRs to main Shows version info and release status before merging: - Version from pyproject.toml - Whether git tag exists - Whether version exists on PyPI - Clear indication if release will happen or be skipped * chore: trigger workflow re-run * fix: Add explicit permissions for checkout in private repo * fix: Add explicit token and permissions to all workflows for private repo * feat: Add typed responses with Pydantic models (v1.1.0) - All resource methods now return typed Pydantic models instead of dicts - Generate proper Enum classes instead of Literal types - Add response models: PostsListResponse, ProfileGetResponse, etc. - Add upload module with direct and Vercel Blob support - Update tests to use attribute access syntax - Sync version to 1.1.0 across pyproject.toml and __init__.py * feat(mcp): Add is_draft parameter and centralized tool definitions - Add is_draft parameter to posts_create and posts_cross_post - Create tool_definitions.py as single source of truth for MCP params - Add script to generate MDX docs from definitions * fix: Move Callable imports to TYPE_CHECKING block and fix trailing whitespace * fix: Fix mypy errors and format code * feat(ai): Add model property to OpenAI provider * Refactor MCP server to use typed models and tool docs Refactors MCP server to use typed model attributes instead of dicts for accounts, profiles, and posts, improving type safety and code clarity. Adds the @use_tool_def decorator to all MCP tool functions, automatically applying centralized docstrings from tool_definitions.py. Updates tool_definitions.py to expand tool documentation, add summaries, and improve MDX generation. Updates README examples to use enums and new tool names. Bumps version to 1.1.1 and regenerates models for improved type annotations. * Update lint config and import Any in server.py Added per-file ignores for generated models in Ruff config to allow old-style annotations and trailing whitespace. Also imported 'Any' from typing in src/late/mcp/server.py. * feat(mcp): Add docs_search tool for documentation search - Add docs_search tool to search Late API documentation - Fetch and cache llms-full.txt with 24h TTL - Score-based search across markdown sections - Returns top 5 relevant documentation sections --------- Co-authored-by: Carlos Martínez --- pyproject.toml | 2 +- src/late/mcp/server.py | 116 +++++++++++++++++++++++++++++++ src/late/mcp/tool_definitions.py | 25 +++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8e7b443..c466735 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "late-sdk" -version = "1.1.1" +version = "1.1.2" description = "Python SDK for Late API - Social Media Scheduling" readme = "README.md" requires-python = ">=3.10" diff --git a/src/late/mcp/server.py b/src/late/mcp/server.py index 47956c5..8f9b506 100644 --- a/src/late/mcp/server.py +++ b/src/late/mcp/server.py @@ -24,15 +24,22 @@ from __future__ import annotations import os +import re from datetime import datetime, timedelta from typing import Any +import httpx from mcp.server.fastmcp import FastMCP from late import Late, MediaType, PostStatus from .tool_definitions import use_tool_def +# Cache for documentation content +_docs_cache: dict[str, tuple[str, datetime]] = {} +_DOCS_URL = "https://docs.getlate.dev/llms-full.txt" +_CACHE_TTL_HOURS = 24 + # Initialize MCP server mcp = FastMCP( "Late", @@ -44,6 +51,7 @@ - profiles_* : Manage profiles (groups of accounts) - posts_* : Create, list, update, delete posts - media_* : Upload images and videos +- docs_* : Search Late API documentation """, ) @@ -635,6 +643,114 @@ def media_check_upload_status(token: str) -> str: return f"❌ Failed to check upload status: {e}" +# ============================================================================ +# DOCS +# ============================================================================ + + +def _get_docs_content() -> str: + """Fetch and cache documentation content.""" + cache_key = "docs" + + # Check cache + if cache_key in _docs_cache: + content, cached_at = _docs_cache[cache_key] + if datetime.now() - cached_at < timedelta(hours=_CACHE_TTL_HOURS): + return content + + # Fetch fresh content + try: + response = httpx.get(_DOCS_URL, timeout=30.0) + response.raise_for_status() + content = response.text + _docs_cache[cache_key] = (content, datetime.now()) + return content + except Exception as e: + # Return cached content if available, even if expired + if cache_key in _docs_cache: + return _docs_cache[cache_key][0] + raise RuntimeError(f"Failed to fetch documentation: {e}") from e + + +def _search_docs(content: str, query: str, max_results: int = 5) -> list[dict[str, str]]: + """Search documentation content for relevant sections.""" + results: list[dict[str, str]] = [] + query_lower = query.lower() + query_terms = query_lower.split() + + # Split content into sections (by markdown headers) + sections = re.split(r'\n(?=#{1,3} )', content) + + scored_sections: list[tuple[int, str, str]] = [] + + for section in sections: + if not section.strip(): + continue + + section_lower = section.lower() + + # Calculate relevance score + score = 0 + + # Exact phrase match (highest priority) + if query_lower in section_lower: + score += 100 + + # Individual term matches + for term in query_terms: + if term in section_lower: + score += 10 + # Bonus for term in header + first_line = section.split('\n')[0].lower() + if term in first_line: + score += 20 + + if score > 0: + # Extract title from first line + lines = section.strip().split('\n') + title = lines[0].lstrip('#').strip() if lines else "Untitled" + scored_sections.append((score, title, section.strip())) + + # Sort by score and take top results + scored_sections.sort(key=lambda x: x[0], reverse=True) + + for score, title, section_text in scored_sections[:max_results]: + # Truncate long sections + if len(section_text) > 1500: + section_text = section_text[:1500] + "\n...(truncated)" + + results.append({ + "title": title, + "content": section_text, + "relevance": str(score), + }) + + return results + + +@mcp.tool() +@use_tool_def("docs_search") +def docs_search(query: str) -> str: + try: + content = _get_docs_content() + results = _search_docs(content, query) + + if not results: + return f"No documentation found for '{query}'. Try different search terms." + + lines = [f"Found {len(results)} relevant section(s) for '{query}':\n"] + + for i, result in enumerate(results, 1): + lines.append(f"--- Result {i}: {result['title']} ---") + lines.append(result["content"]) + lines.append("") + + return "\n".join(lines) + + except Exception as e: + return f"❌ Failed to search documentation: {e}" + + # ============================================================================ # MAIN # ============================================================================ diff --git a/src/late/mcp/tool_definitions.py b/src/late/mcp/tool_definitions.py index a50f61e..4577afe 100644 --- a/src/late/mcp/tool_definitions.py +++ b/src/late/mcp/tool_definitions.py @@ -543,6 +543,28 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: params=[], ) +# ============================================================================= +# DOCS TOOLS +# ============================================================================= + +DOCS_SEARCH = ToolDef( + name="docs_search", + summary="Search the Late API documentation.", + description="""Search across the Late API documentation to find relevant information, code examples, API references, and guides. + +Use this tool when you need to answer questions about Late, find specific documentation, understand how features work, or locate implementation details. + +The search returns contextual content with section titles and relevant snippets.""", + params=[ + ParamDef( + name="query", + type="str", + description="Search query (e.g., 'webhooks', 'create post', 'authentication')", + required=True, + ), + ], +) + # ============================================================================= # MEDIA TOOLS # ============================================================================= @@ -608,6 +630,8 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: # Media "media_generate_upload_link": MEDIA_GENERATE_UPLOAD_LINK, "media_check_upload_status": MEDIA_CHECK_UPLOAD_STATUS, + # Docs + "docs_search": DOCS_SEARCH, } @@ -648,6 +672,7 @@ def generate_mdx_tools_reference() -> str: "posts_retry_all_failed", ], "Media": ["media_generate_upload_link", "media_check_upload_status"], + "Docs": ["docs_search"], } for category, tool_names in categories.items(): From 0e01e7798f15499c181b51991825861d8cb272fd Mon Sep 17 00:00:00 2001 From: elean-latedev Date: Fri, 6 Mar 2026 14:50:53 +0100 Subject: [PATCH 2/2] fix: use UTC-aware datetimes to prevent MCP scheduling in the past datetime.now() returns naive local time, which .isoformat() serializes without a timezone suffix. The API interprets this as UTC, so users in non-UTC timezones (e.g. PST) get scheduledFor shifted into the past, triggering immediate publish instead of scheduling. Fix: datetime.now(timezone.utc) across all scheduling, caching, and rate-limiting code. Display now appends "UTC" for clarity. Reported by: Harshil Shah (Crisp session 3c3c8df5) Co-Authored-By: Claude Opus 4.6 --- README.md | 4 ++-- examples/01_basic_usage.py | 4 ++-- examples/publish_post.py | 4 ++-- src/late/client/rate_limiter.py | 7 ++++--- src/late/mcp/server.py | 16 ++++++++++------ src/late/pipelines/cross_poster.py | 8 +++++--- src/late/resources/posts.py | 3 ++- tests/test_integration.py | 6 +++--- 8 files changed, 30 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 64a4a0b..32cd7b4 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ pip install late-sdk ## Quick Start ```python -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from late import Late, Platform client = Late(api_key="your_api_key") @@ -29,7 +29,7 @@ accounts = client.accounts.list() post = client.posts.create( content="Hello from Late!", platforms=[{"platform": Platform.TWITTER, "accountId": "your_account_id"}], - scheduled_for=datetime.now() + timedelta(hours=1), + scheduled_for=datetime.now(timezone.utc) + timedelta(hours=1), ) ``` diff --git a/examples/01_basic_usage.py b/examples/01_basic_usage.py index 071445f..e2b0319 100644 --- a/examples/01_basic_usage.py +++ b/examples/01_basic_usage.py @@ -8,7 +8,7 @@ """ import os -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from late import Late @@ -43,7 +43,7 @@ def main() -> None: # Create a new post (example - uncomment to use) # if accounts.get("accounts"): # account = accounts["accounts"][0] - # scheduled_time = datetime.now() + timedelta(hours=1) + # scheduled_time = datetime.now(timezone.utc) + timedelta(hours=1) # # post = client.posts.create( # content="Hello from Late Python SDK!", diff --git a/examples/publish_post.py b/examples/publish_post.py index 017d796..30e4fa6 100644 --- a/examples/publish_post.py +++ b/examples/publish_post.py @@ -3,7 +3,7 @@ """ import os -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from late import Late @@ -29,7 +29,7 @@ def main() -> None: print(f"\nUsing: {account['platform']} ({account.get('username', '')})") # 3. Create post - scheduled for 1 hour from now - scheduled_time = datetime.now() + timedelta(hours=1) + scheduled_time = datetime.now(timezone.utc) + timedelta(hours=1) post = client.posts.create( content="Hello from Late Python SDK! 🚀", diff --git a/src/late/client/rate_limiter.py b/src/late/client/rate_limiter.py index 2d06341..75a7fdd 100644 --- a/src/late/client/rate_limiter.py +++ b/src/late/client/rate_limiter.py @@ -6,7 +6,7 @@ import contextlib from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -31,7 +31,7 @@ def seconds_until_reset(self) -> float | None: """Get seconds until rate limit resets.""" if self.reset is None: return None - delta = self.reset - datetime.now() + delta = self.reset - datetime.now(timezone.utc) return max(0.0, delta.total_seconds()) @@ -90,7 +90,8 @@ def update_from_headers(self, headers: Mapping[str, str]) -> None: if reset_str is not None: try: timestamp = int(reset_str) - self._info.reset = datetime.fromtimestamp(timestamp) + # Use UTC so the reset comparison is timezone-consistent. + self._info.reset = datetime.fromtimestamp(timestamp, tz=timezone.utc) except (ValueError, OSError): pass diff --git a/src/late/mcp/server.py b/src/late/mcp/server.py index 8f9b506..1176135 100644 --- a/src/late/mcp/server.py +++ b/src/late/mcp/server.py @@ -25,7 +25,7 @@ import os import re -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any import httpx @@ -334,7 +334,9 @@ def posts_create( params["publish_now"] = True else: minutes = schedule_minutes if schedule_minutes > 0 else 60 - params["scheduled_for"] = datetime.now() + timedelta(minutes=minutes) + # Use UTC so the scheduled time matches what the API expects, + # regardless of the user's local timezone. + params["scheduled_for"] = datetime.now(timezone.utc) + timedelta(minutes=minutes) response = client.posts.create(**params) post = response.post @@ -352,7 +354,7 @@ def posts_create( elif publish_now: return f"✅ Published to {platform} (@{username}){media_info}\nPost ID: {post_id}" else: - scheduled = params["scheduled_for"].strftime("%Y-%m-%d %H:%M") + scheduled = params["scheduled_for"].strftime("%Y-%m-%d %H:%M UTC") return f"✅ Scheduled for {platform} (@{username}){media_info}\nPost ID: {post_id}\nScheduled: {scheduled}" @@ -422,7 +424,9 @@ def posts_cross_post( elif publish_now: params["publish_now"] = True else: - params["scheduled_for"] = datetime.now() + timedelta(hours=1) + # Use UTC so the scheduled time matches what the API expects, + # regardless of the user's local timezone. + params["scheduled_for"] = datetime.now(timezone.utc) + timedelta(hours=1) response = client.posts.create(**params) post = response.post @@ -655,7 +659,7 @@ def _get_docs_content() -> str: # Check cache if cache_key in _docs_cache: content, cached_at = _docs_cache[cache_key] - if datetime.now() - cached_at < timedelta(hours=_CACHE_TTL_HOURS): + if datetime.now(timezone.utc) - cached_at < timedelta(hours=_CACHE_TTL_HOURS): return content # Fetch fresh content @@ -663,7 +667,7 @@ def _get_docs_content() -> str: response = httpx.get(_DOCS_URL, timeout=30.0) response.raise_for_status() content = response.text - _docs_cache[cache_key] = (content, datetime.now()) + _docs_cache[cache_key] = (content, datetime.now(timezone.utc)) return content except Exception as e: # Return cached content if available, even if expired diff --git a/src/late/pipelines/cross_poster.py b/src/late/pipelines/cross_poster.py index 20861f9..fff4ea4 100644 --- a/src/late/pipelines/cross_poster.py +++ b/src/late/pipelines/cross_poster.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Any from late.enums import Platform @@ -111,7 +111,8 @@ async def post( List of results per platform """ results: list[CrossPostResult] = [] - current_time = base_time or datetime.now() + # Use UTC so scheduled times match what the API expects. + current_time = base_time or datetime.now(timezone.utc) for i, config in enumerate(platforms): try: @@ -165,7 +166,8 @@ def post_sync( ) -> list[CrossPostResult]: """Synchronous version of cross-posting.""" results: list[CrossPostResult] = [] - current_time = kwargs.get("base_time") or datetime.now() + # Use UTC so scheduled times match what the API expects. + current_time = kwargs.get("base_time") or datetime.now(timezone.utc) adapt = kwargs.get("adapt_content", True) for i, config in enumerate(platforms): diff --git a/src/late/resources/posts.py b/src/late/resources/posts.py index 31a412c..b514a10 100644 --- a/src/late/resources/posts.py +++ b/src/late/resources/posts.py @@ -29,6 +29,7 @@ class PostsResource(BaseResource[PostsListResponse]): Resource for managing posts. Example: + >>> from datetime import datetime, timedelta, timezone >>> from late import Platform, PostStatus >>> client = Late(api_key="...") >>> # List posts @@ -37,7 +38,7 @@ class PostsResource(BaseResource[PostsListResponse]): >>> post = client.posts.create( ... content="Hello!", ... platforms=[{"platform": Platform.TWITTER, "accountId": "..."}], - ... scheduled_for=datetime.now() + timedelta(hours=1), + ... scheduled_for=datetime.now(timezone.utc) + timedelta(hours=1), ... ) >>> # Update a post >>> client.posts.update(post_id, content="Updated!") diff --git a/tests/test_integration.py b/tests/test_integration.py index 2e84a16..b9421cb 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -13,7 +13,7 @@ from __future__ import annotations import json -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any import httpx @@ -163,7 +163,7 @@ def test_create_post_scheduled(self, client: Late, mock_post: dict) -> None: ) ) - scheduled_time = datetime.now() + timedelta(hours=1) + scheduled_time = datetime.now(timezone.utc) + timedelta(hours=1) result = client.posts.create( content="Test post", platforms=[{"platform": "twitter", "accountId": "acc_123"}], @@ -277,7 +277,7 @@ def test_update_post(self, client: Late, mock_post: dict) -> None: client.posts.update( "post_123", content="Updated content", - scheduled_for=datetime.now() + timedelta(days=1), + scheduled_for=datetime.now(timezone.utc) + timedelta(days=1), ) assert route.called