composition_tools.py•24.9 kB
# src/chuk_mcp_linkedin/tools/composition_tools.py
"""
Composition tools for building LinkedIn posts.
Thin wrapper around ComposablePost for MCP tool integration.
All tools require OAuth authorization to prevent server abuse and enable
user-scoped data persistence across sessions.
"""
from typing import Any, Dict, List, Optional
from chuk_mcp_server.decorators import requires_auth
from ..manager_factory import get_current_manager
from ..posts.composition import ComposablePost
from ..themes.theme_manager import ThemeManager
# Cache of ComposablePost instances per draft ID
_post_cache: Dict[str, ComposablePost] = {}
def _get_or_create_post() -> ComposablePost:
"""Get or create ComposablePost instance for current draft."""
manager = get_current_manager()
draft = manager.get_current_draft()
if not draft:
raise ValueError("No active draft")
# Get or create ComposablePost instance from cache
if draft.draft_id not in _post_cache:
# Create new ComposablePost
theme = None
if draft.theme:
theme_mgr = ThemeManager()
theme = theme_mgr.get_theme(draft.theme)
post = ComposablePost(draft.post_type, theme=theme, variant_config=draft.variant_config)
_post_cache[draft.draft_id] = post
result: ComposablePost = _post_cache[draft.draft_id]
return result
def clear_post_cache(draft_id: Optional[str] = None) -> None:
"""
Clear the ComposablePost cache.
Args:
draft_id: Specific draft ID to clear, or None to clear all
"""
if draft_id:
_post_cache.pop(draft_id, None)
else:
_post_cache.clear()
def register_composition_tools(mcp: Any) -> Dict[str, Any]:
"""Register composition tools with the MCP server"""
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_hook(
hook_type: str, content: str, _external_access_token: Optional[str] = None
) -> str:
"""
Add opening hook to current draft.
Args:
hook_type: Type of hook (question, stat, story, controversy, list, curiosity)
content: Hook text
Returns:
Success message
"""
try:
post = _get_or_create_post()
post.add_hook(hook_type, content)
return f"Added {hook_type} hook to draft"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_body(
content: str, structure: str = "linear", _external_access_token: Optional[str] = None
) -> str:
"""
Add main content body to current draft.
Args:
content: Body text
structure: Content structure (linear, listicle, framework, story_arc, comparison)
Returns:
Success message
"""
try:
post = _get_or_create_post()
post.add_body(content, structure=structure)
return f"Added body with {structure} structure"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_cta(
cta_type: str, text: str, _external_access_token: Optional[str] = None
) -> str:
"""
Add call-to-action to current draft.
Args:
cta_type: Type of CTA (direct, curiosity, action, share, soft)
text: CTA text
Returns:
Success message
"""
try:
post = _get_or_create_post()
post.add_cta(cta_type, text)
return f"Added {cta_type} CTA"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_bar_chart(
data: Dict[str, int],
title: Optional[str] = None,
unit: str = "",
_external_access_token: Optional[str] = None,
) -> str:
"""
Add horizontal bar chart using colored emoji squares.
Args:
data: Chart data with labels and integer values (e.g., {"AI-Assisted": 12, "Code Review": 6})
title: Optional chart title
unit: Optional unit label (e.g., "hours", "users", "tasks")
Returns:
Success message
Example:
data={"AI-Assisted": 12, "Code Review": 6, "Documentation": 4}, unit="hours"
"""
try:
post = _get_or_create_post()
post.add_bar_chart(data, title=title, unit=unit)
return f"Added bar chart with {len(data)} bars"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_metrics_chart(
data: Dict[str, str],
title: Optional[str] = None,
_external_access_token: Optional[str] = None,
) -> str:
"""
Add key metrics chart with emoji indicators (✅/❌).
Args:
data: Metrics data with labels and string values (e.g., {"Faster problem-solving": "67%"})
title: Optional chart title
Returns:
Success message
Example:
data={"Faster problem-solving": "67%", "Better learning": "89%"}
"""
try:
post = _get_or_create_post()
post.add_metrics_chart(data, title=title)
return f"Added metrics chart with {len(data)} metrics"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_comparison_chart(
data: Dict[str, Any],
title: Optional[str] = None,
_external_access_token: Optional[str] = None,
) -> str:
"""
Add side-by-side A vs B comparison chart.
Args:
data: Comparison data with 2+ options (values can be strings or lists)
title: Optional chart title
Returns:
Success message
Example:
data={
"Traditional Dev": ["Slower iterations", "Manual testing"],
"AI-Assisted Dev": ["Faster prototyping", "Automated tests"]
}
"""
try:
post = _get_or_create_post()
post.add_comparison_chart(data, title=title)
return f"Added comparison chart with {len(data)} options"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_progress_chart(
data: Dict[str, int],
title: Optional[str] = None,
_external_access_token: Optional[str] = None,
) -> str:
"""
Add progress bars chart for tracking completion (0-100%).
Args:
data: Progress data with labels and percentage values (e.g., {"Completion": 75})
title: Optional chart title
Returns:
Success message
Example:
data={"Completion": 75, "Testing": 50, "Documentation": 30}
"""
try:
post = _get_or_create_post()
post.add_progress_chart(data, title=title)
return f"Added progress chart with {len(data)} items"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_ranking_chart(
data: Dict[str, str],
title: Optional[str] = None,
show_medals: bool = True,
_external_access_token: Optional[str] = None,
) -> str:
"""
Add ranking/leaderboard chart with medals (🥇🥈🥉) for top 3.
Args:
data: Ranking data with labels and descriptions (e.g., {"Python": "1M users"})
title: Optional chart title
show_medals: Show medal emojis for top 3 positions (default: true)
Returns:
Success message
Example:
data={"Python": "1M users", "JavaScript": "900K users", "Rust": "500K users"}
"""
try:
post = _get_or_create_post()
post.add_ranking_chart(data, title=title, show_medals=show_medals)
return f"Added ranking chart with {len(data)} items"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_quote(
text: str,
author: str,
source: Optional[str] = None,
_external_access_token: Optional[str] = None,
) -> str:
"""
Add a quote or testimonial to current draft.
Args:
text: Quote text (max 500 chars)
author: Quote author name
source: Optional source/attribution (e.g., "CTO at TechCorp")
Returns:
Success message
Example:
text="AI has transformed our development process",
author="Sarah Chen",
source="CTO at TechCorp"
"""
try:
post = _get_or_create_post()
post.add_quote(text, author, source=source)
return f"Added quote from {author}"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_big_stat(
number: str,
label: str,
context: Optional[str] = None,
_external_access_token: Optional[str] = None,
) -> str:
"""
Add a big eye-catching statistic to current draft.
Args:
number: Statistic number (e.g., "2.5M", "340%", "10x")
label: Description of what the number represents
context: Optional additional context (e.g., "↑ 340% YoY growth")
Returns:
Success message
Example:
number="2.5M",
label="developers using AI tools daily",
context="↑ 340% growth year-over-year"
"""
try:
post = _get_or_create_post()
post.add_big_stat(number, label, context=context)
return f"Added big stat: {number}"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_timeline(
steps: Dict[str, str],
title: Optional[str] = None,
style: str = "arrow",
_external_access_token: Optional[str] = None,
) -> str:
"""
Add a timeline or step-by-step process to current draft.
Args:
steps: Timeline steps with dates/labels and descriptions
title: Optional timeline title
style: Timeline style (arrow, numbered, dated) - default: arrow
Returns:
Success message
Example:
steps={"Jan 2023": "Launched MVP", "Jun 2023": "Reached 1K users"},
title="OUR JOURNEY",
style="arrow"
"""
try:
post = _get_or_create_post()
post.add_timeline(steps, title=title, style=style)
return f"Added timeline with {len(steps)} steps"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_key_takeaway(
message: str,
title: str = "KEY TAKEAWAY",
style: str = "box",
_external_access_token: Optional[str] = None,
) -> str:
"""
Add a highlighted key insight or TLDR to current draft.
Args:
message: The key takeaway message (max 500 chars)
title: Box title (default: "KEY TAKEAWAY")
style: Display style (box, highlight, simple) - default: box
Returns:
Success message
Example:
message="Focus on solving real problems, not chasing trends",
title="KEY TAKEAWAY",
style="box"
"""
try:
post = _get_or_create_post()
post.add_key_takeaway(message, title=title, style=style)
return "Added key takeaway"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_pro_con(
pros: List[str],
cons: List[str],
title: Optional[str] = None,
_external_access_token: Optional[str] = None,
) -> str:
"""
Add a pros & cons comparison to current draft.
Args:
pros: List of pros/advantages
cons: List of cons/disadvantages
title: Optional comparison title
Returns:
Success message
Example:
pros=["40% faster development", "Better code quality"],
cons=["Initial learning curve", "$20-40 per developer/month"],
title="AI CODING TOOLS"
"""
try:
post = _get_or_create_post()
post.add_pro_con(pros, cons, title=title)
return f"Added pro/con comparison ({len(pros)} pros, {len(cons)} cons)"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_separator(
style: str = "line", _external_access_token: Optional[str] = None
) -> str:
"""
Add a visual separator to current draft.
Args:
style: Separator style (line, dots, wave) - default: line
Returns:
Success message
"""
try:
post = _get_or_create_post()
post.add_separator(style=style)
return f"Added {style} separator"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_checklist(
items: List[Dict[str, Any]],
title: Optional[str] = None,
show_progress: bool = False,
_external_access_token: Optional[str] = None,
) -> str:
"""
Add checklist with checkmarks to current draft.
Args:
items: Checklist items (each with 'text' and optional 'checked')
title: Optional checklist title
show_progress: Show completion progress
Returns:
Success message
"""
try:
post = _get_or_create_post()
post.add_checklist(items, title=title, show_progress=show_progress)
return f"Added checklist with {len(items)} items"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_before_after(
before: List[str],
after: List[str],
title: Optional[str] = None,
labels: Optional[Dict[str, str]] = None,
_external_access_token: Optional[str] = None,
) -> str:
"""
Add before/after transformation comparison.
Args:
before: List of 'before' items
after: List of 'after' items
title: Optional comparison title
labels: Custom labels (e.g., {"before": "Old Way", "after": "New Way"})
Returns:
Success message
"""
try:
post = _get_or_create_post()
post.add_before_after(before, after, title=title, labels=labels)
return "Added before/after comparison"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_tip_box(
message: str,
title: Optional[str] = None,
style: str = "info",
_external_access_token: Optional[str] = None,
) -> str:
"""
Add highlighted tip/note box.
Args:
message: Tip or note message
title: Optional tip box title
style: Box style (info, tip, warning, success)
Returns:
Success message
"""
try:
post = _get_or_create_post()
post.add_tip_box(message, title=title, style=style)
return f"Added {style} tip box"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_stats_grid(
stats: Dict[str, str],
title: Optional[str] = None,
columns: int = 2,
_external_access_token: Optional[str] = None,
) -> str:
"""
Add multi-stat grid display.
Args:
stats: Statistics as key-value pairs
title: Optional grid title
columns: Number of columns (1-4)
Returns:
Success message
"""
try:
post = _get_or_create_post()
post.add_stats_grid(stats, title=title, columns=columns)
return f"Added stats grid with {len(stats)} stats"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_poll_preview(
question: str, options: List[str], _external_access_token: Optional[str] = None
) -> str:
"""
Add poll preview for engagement.
Args:
question: Poll question
options: Poll options (2-4)
Returns:
Success message
"""
try:
post = _get_or_create_post()
post.add_poll_preview(question, options)
return f"Added poll with {len(options)} options"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_feature_list(
features: List[Dict[str, str]],
title: Optional[str] = None,
_external_access_token: Optional[str] = None,
) -> str:
"""
Add feature list with icons and descriptions.
Args:
features: Features (each with 'title', optional 'icon' and 'description')
title: Optional feature list title
Returns:
Success message
"""
try:
post = _get_or_create_post()
post.add_feature_list(features, title=title)
return f"Added feature list with {len(features)} features"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_numbered_list(
items: List[str],
title: Optional[str] = None,
style: str = "numbers",
start: int = 1,
_external_access_token: Optional[str] = None,
) -> str:
"""
Add enhanced numbered list.
Args:
items: List items
title: Optional list title
style: Numbering style (numbers, emoji_numbers, bold_numbers)
start: Starting number
Returns:
Success message
"""
try:
post = _get_or_create_post()
post.add_numbered_list(items, title=title, style=style, start=start)
return f"Added numbered list with {len(items)} items"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_add_hashtags(
tags: List[str], placement: str = "end", _external_access_token: Optional[str] = None
) -> str:
"""
Add hashtags to current draft.
Args:
tags: List of hashtags (without #)
placement: Where to place hashtags (inline, mid, end, first_comment)
Returns:
Success message
"""
try:
post = _get_or_create_post()
post.add_hashtags(tags, placement=placement)
return f"Added {len(tags)} hashtags"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_compose_post(
optimize: bool = True, _external_access_token: Optional[str] = None
) -> str:
"""
Compose post from components in current draft.
Args:
optimize: Optimize for engagement (default: true)
Returns:
Composed post text
"""
try:
post = _get_or_create_post()
# Optimize if requested
if optimize:
post.optimize_for_engagement()
# Compose final text
final_text = post.compose()
# Update draft with composed text
manager = get_current_manager()
draft = manager.get_current_draft()
if not draft:
return "No active draft"
draft.content["composed_text"] = final_text
manager.update_draft(draft.draft_id, content=draft.content)
return f"Composed post ({len(final_text)} chars):\n\n{final_text}"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_get_preview(_external_access_token: Optional[str] = None) -> str:
"""
Get preview of current draft (first 210 chars).
Returns:
Preview text showing what appears before "see more"
"""
try:
post = _get_or_create_post()
preview = post.get_preview(210)
return f"Preview (first 210 chars):\n\n{preview}"
except ValueError as e:
return str(e)
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_preview_html(
base_url: str = "http://localhost:8000",
open_browser: bool = True,
_external_access_token: Optional[str] = None,
) -> str:
"""
Generate HTML preview of current draft and get shareable URL.
Args:
base_url: Base URL of the server (default: http://localhost:8000)
open_browser: Auto-open preview in browser (default: true)
Returns:
Preview URL
"""
manager = get_current_manager()
draft = manager.get_current_draft()
if not draft:
return "No active draft"
# Generate preview URL
preview_url = await manager.generate_preview_url(
draft_id=draft.draft_id, base_url=base_url, expires_in=3600
)
if not preview_url:
return "Failed to generate preview URL"
# Open in browser if requested
if open_browser:
import webbrowser
webbrowser.open(preview_url)
return f"Preview generated and opened in browser:\n{preview_url}"
else:
return f"Preview URL:\n{preview_url}\n\nOpen this URL in your browser to view."
@mcp.tool # type: ignore[misc]
@requires_auth()
async def linkedin_export_draft(_external_access_token: Optional[str] = None) -> str:
"""
Export current draft as JSON.
Returns:
JSON string of draft
"""
manager = get_current_manager()
draft = manager.get_current_draft()
if not draft:
return "No active draft"
export_json = manager.export_draft(draft.draft_id)
return export_json or "Export failed"
return {
# Content components
"linkedin_add_hook": linkedin_add_hook,
"linkedin_add_body": linkedin_add_body,
"linkedin_add_cta": linkedin_add_cta,
"linkedin_add_hashtags": linkedin_add_hashtags,
# Data visualization components
"linkedin_add_bar_chart": linkedin_add_bar_chart,
"linkedin_add_metrics_chart": linkedin_add_metrics_chart,
"linkedin_add_comparison_chart": linkedin_add_comparison_chart,
"linkedin_add_progress_chart": linkedin_add_progress_chart,
"linkedin_add_ranking_chart": linkedin_add_ranking_chart,
# Feature components
"linkedin_add_quote": linkedin_add_quote,
"linkedin_add_big_stat": linkedin_add_big_stat,
"linkedin_add_timeline": linkedin_add_timeline,
"linkedin_add_key_takeaway": linkedin_add_key_takeaway,
"linkedin_add_pro_con": linkedin_add_pro_con,
"linkedin_add_checklist": linkedin_add_checklist,
"linkedin_add_before_after": linkedin_add_before_after,
"linkedin_add_tip_box": linkedin_add_tip_box,
"linkedin_add_stats_grid": linkedin_add_stats_grid,
"linkedin_add_poll_preview": linkedin_add_poll_preview,
"linkedin_add_feature_list": linkedin_add_feature_list,
"linkedin_add_numbered_list": linkedin_add_numbered_list,
# Layout components
"linkedin_add_separator": linkedin_add_separator,
# Composition & preview
"linkedin_compose_post": linkedin_compose_post,
"linkedin_get_preview": linkedin_get_preview,
"linkedin_preview_html": linkedin_preview_html,
"linkedin_export_draft": linkedin_export_draft,
}