creatorloop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
creatorloop/server.py ADDED
@@ -0,0 +1,816 @@
1
+ """MCP server: tool definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import functools
8
+ import hashlib
9
+ import logging
10
+ import os
11
+ import sys
12
+ from collections.abc import AsyncIterator
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import Annotated, Any, Literal
16
+
17
+ from mcp.server.mcpserver import Image, MCPServer
18
+ from mcp.server.mcpserver.exceptions import ToolError
19
+ from mcp_types import ToolAnnotations
20
+ from pydantic import Field
21
+
22
+ from creatorloop import __version__, analytics, comments, post_queue, reviews
23
+ from creatorloop.config import load_settings
24
+ from creatorloop.graph_client import GraphAPIError, GraphClient
25
+ from creatorloop.instagram import InstagramService
26
+ from creatorloop.publish import Publisher, PublishError
27
+ from creatorloop.refs import RefFinder, previews
28
+ from creatorloop.storage import Storage
29
+
30
+ # stdout is the MCP stdio channel: logs must go to stderr only.
31
+ logging.basicConfig(level=logging.INFO, stream=sys.stderr, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
32
+ log = logging.getLogger("creatorloop")
33
+
34
+ INSTRUCTIONS = """\
35
+ creatorloop: a content studio for one Instagram professional account, via the official Instagram Graph API.
36
+ Loop: analyse what works -> plan and make posts/Reels -> publish or queue them -> review results after 24/72 h.
37
+ - Publishing is always two-step: show the preview and publish only after the user explicitly confirms.
38
+ - Own account: full private insights (reach, views, saves, shares, watch time, demographics).
39
+ - Competitors: only public counters of professional accounts (followers, likes, comments, Reels views).
40
+ Personal/private accounts cannot be analysed.
41
+ - Hashtags: max 30 unique hashtags per rolling 7 days. Check get_api_status before bulk hashtag research.
42
+ - Results are cached in SQLite; pass refresh=true to force fresh data.
43
+ Answer the user in the language they write in.
44
+ """
45
+
46
+ _service: InstagramService | None = None
47
+ JOBS: dict[str, dict[str, Any]] = {} # background prepare_post jobs (the device bridge times out calls at 60 s)
48
+
49
+ PACKAGE_DIR = Path(__file__).resolve().parent
50
+
51
+
52
+ def code_version() -> str:
53
+ """Short hash of the package sources currently on disk."""
54
+ h = hashlib.sha256()
55
+ for f in sorted(PACKAGE_DIR.glob("*.py")):
56
+ h.update(f.name.encode())
57
+ h.update(f.read_bytes())
58
+ return h.hexdigest()[:12]
59
+
60
+
61
+ def deps_version() -> str:
62
+ """Hash of pyproject.toml + uv.lock: if these change, `uv run` rebuilds the venv on start,
63
+ which fails when Claude Desktop restarts the server — a manual Desktop restart is needed then.
64
+ Empty when installed from PyPI: there is no project to rebuild."""
65
+ files = [f for f in (PACKAGE_DIR.parents[1] / name for name in ("pyproject.toml", "uv.lock")) if f.exists()]
66
+ if not files:
67
+ return ""
68
+ h = hashlib.sha256()
69
+ for f in files:
70
+ h.update(f.read_bytes())
71
+ return h.hexdigest()[:12]
72
+
73
+
74
+ STARTED_AT = datetime.now(UTC).isoformat(timespec="seconds")
75
+ RUNNING_VERSION = code_version()
76
+ RUNNING_DEPS = deps_version()
77
+
78
+
79
+ @contextlib.asynccontextmanager
80
+ async def lifespan(_server: MCPServer) -> AsyncIterator[None]:
81
+ """Start the publishing-queue runner; close the shared HTTP client when the server stops."""
82
+ runner = asyncio.create_task(post_queue.QueueRunner(service).run_forever())
83
+ try:
84
+ yield
85
+ finally:
86
+ runner.cancel()
87
+ global _service
88
+ if _service is not None:
89
+ await _service.client.aclose()
90
+ _service = None
91
+ log.info("Graph API client closed")
92
+
93
+
94
+ mcp = MCPServer(name="creatorloop", version=__version__, instructions=INSTRUCTIONS, lifespan=lifespan)
95
+ RO = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
96
+
97
+
98
+
99
+ def tool(**kwargs: Any):
100
+ """mcp.tool + turn expected errors into ToolError, so the model sees the real message
101
+ (mcp 2.x hides the text of any other exception)."""
102
+
103
+ def decorator(fn):
104
+ @functools.wraps(fn)
105
+ async def wrapper(*args: Any, **kw: Any) -> Any:
106
+ try:
107
+ result = await fn(*args, **kw)
108
+ if isinstance(result, dict) and _service is not None and fn.__name__ != "get_api_status":
109
+ if warning := await _service.token_warning():
110
+ result["token_warning"] = warning
111
+ return result
112
+ except ToolError:
113
+ raise
114
+ except (GraphAPIError, RuntimeError, PublishError) as exc:
115
+ raise ToolError(str(exc)) from exc
116
+
117
+ return mcp.tool(**kwargs)(wrapper)
118
+
119
+ return decorator
120
+
121
+
122
+ def service() -> InstagramService:
123
+ global _service
124
+ if _service is None:
125
+ settings = load_settings()
126
+ settings.require_credentials()
127
+ client = GraphClient(settings.base_url, settings.access_token)
128
+ _service = InstagramService(settings, client, Storage(settings.db_path))
129
+ return _service
130
+
131
+
132
+ def _compact_profile(profile: dict[str, Any]) -> dict[str, Any]:
133
+ """Drop bulky fields the model never needs (signed CDN avatar URL)."""
134
+ return {k: v for k, v in profile.items() if k != "profile_picture_url"}
135
+
136
+
137
+ # ---- own account -----------------------------------------------------------
138
+
139
+
140
+ @tool(annotations=RO)
141
+ async def get_my_profile(refresh: bool = False) -> dict[str, Any]:
142
+ """Own profile: username, bio, followers, following, posts count. Also saves a daily follower snapshot."""
143
+ return _compact_profile(await service().my_profile(refresh=refresh))
144
+
145
+
146
+ @tool(annotations=RO)
147
+ async def get_my_media(
148
+ limit: Annotated[int, Field(ge=1, le=500)] = 25,
149
+ refresh: bool = False,
150
+ ) -> list[dict[str, Any]]:
151
+ """Latest own posts/Reels (newest first) with public counters: likes, comments, type, permalink, timestamp."""
152
+ return await service().my_media(limit=limit, refresh=refresh)
153
+
154
+
155
+ @tool(annotations=RO)
156
+ async def get_media_insights(media_id: str) -> dict[str, Any]:
157
+ """Private insights for ONE own post/Reel/Story: reach, views, saved, shares, profile visits, follows,
158
+ Reels average watch time (ms). Metrics the API refuses are listed in `unavailable`."""
159
+ return await service().media_insights(media_id)
160
+
161
+
162
+ @tool(annotations=RO)
163
+ async def get_my_content_report(
164
+ days: Annotated[int, Field(ge=1, le=365, description="Analyse posts published in the last N days")] = 30,
165
+ rank_by: Literal["reach", "views", "saved", "shares", "total_interactions", "engagement_rate"] = "reach",
166
+ max_posts: Annotated[int, Field(ge=1, le=200)] = 50,
167
+ ) -> dict[str, Any]:
168
+ """Full content report for own account: best/worst posts with hooks, format comparison (Reels vs carousel
169
+ vs image), weighted engagement rate, follows/profile visits per 1k reach, Reels watch time,
170
+ posting frequency, best weekday/hour, most used hashtags, posts still growing (<72h).
171
+ Makes 1 insights request per post (cached), so large max_posts is slower."""
172
+ svc = service()
173
+ enriched = await _own_media_with_insights(days, max_posts)
174
+ report = analytics.summarize_own_media(enriched, svc.settings.timezone, rank_by=rank_by)
175
+ report["period_days"] = days
176
+ report["engagement_rate_formula"] = (
177
+ "sum(likes + comments + saved + shares) / sum(reach) * 100 over the group (weighted, not a mean of ratios)"
178
+ )
179
+ return report
180
+
181
+
182
+ async def _own_media_with_insights(days: int, max_posts: int) -> list[dict[str, Any]]:
183
+ svc = service()
184
+ media = analytics.media_since(await svc.my_media(limit=max_posts), days)
185
+ sem = asyncio.Semaphore(5)
186
+
187
+ async def enrich(m: dict[str, Any]) -> dict[str, Any]:
188
+ async with sem:
189
+ ins = await svc.media_insights(m["id"], m.get("media_product_type"), media=m)
190
+ metrics = ins["metrics"]
191
+ return {**m, "insights": metrics, "engagement_rate": analytics.own_engagement_rate(metrics)}
192
+
193
+ return list(await asyncio.gather(*(enrich(m) for m in media)))
194
+
195
+
196
+ @tool(annotations=RO)
197
+ async def get_caption_insights(
198
+ username: Annotated[str | None, Field(description="Empty = own account (by reach); else a competitor")] = None,
199
+ days: Annotated[int, Field(ge=7, le=365)] = 90,
200
+ max_posts: Annotated[int, Field(ge=5, le=200)] = 50,
201
+ ) -> dict[str, Any]:
202
+ """What in captions works: language (uz/ru/en), question in the hook, call-to-action, caption length;
203
+ best and worst hooks (first line). Own account is scored by reach, competitors by likes+comments."""
204
+ svc = service()
205
+ if username:
206
+ data = await svc.competitor(username, media_limit=max_posts)
207
+ media = analytics.media_since(data.get("media", []), days)
208
+ result = analytics.caption_breakdown(media, own=False)
209
+ result["username"] = username.lstrip("@").lower()
210
+ else:
211
+ result = analytics.caption_breakdown(await _own_media_with_insights(days, max_posts), own=True)
212
+ result["username"] = await svc.my_username()
213
+ result["period_days"] = days
214
+ return result
215
+
216
+
217
+ @tool(annotations=RO)
218
+ async def get_account_insights(
219
+ days: Annotated[int, Field(ge=1, le=90, description="Period length in days, ending yesterday")] = 30,
220
+ ) -> dict[str, Any]:
221
+ """Account-level totals for the period: reach, views, accounts engaged, interactions, likes, comments,
222
+ saves, shares, link taps, follows/unfollows, plus daily reach. Meta data can lag up to 48h."""
223
+ return await service().account_insights(days)
224
+
225
+
226
+ @tool(annotations=RO)
227
+ async def get_audience_demographics(
228
+ breakdown: Literal["country", "city", "age", "gender"] = "country",
229
+ timeframe: Literal["this_week", "this_month", "prev_month", "last_14_days", "last_30_days", "last_90_days"] = "this_month",
230
+ engaged: Annotated[bool, Field(description="True = engaged audience, False = all followers")] = False,
231
+ ) -> dict[str, Any]:
232
+ """Audience demographics (top 45 rows). Requires 100+ followers (or 100+ engagements for engaged=True)."""
233
+ return await service().demographics(breakdown, timeframe, engaged)
234
+
235
+
236
+ # ---- competitors -----------------------------------------------------------
237
+
238
+
239
+ @tool(annotations=RO)
240
+ async def get_competitor(
241
+ username: Annotated[str, Field(description="Instagram username, with or without @")],
242
+ media_limit: Annotated[int, Field(ge=1, le=200)] = 30,
243
+ refresh: bool = False,
244
+ ) -> dict[str, Any]:
245
+ """Public analysis of another PROFESSIONAL (business/creator) account via Business Discovery:
246
+ profile, ER by followers, formats, posting frequency, best times, hashtags, top/worst posts.
247
+ Saves a daily follower snapshot so growth can be tracked with get_follower_history."""
248
+ svc = service()
249
+ data = await svc.competitor(username, media_limit=media_limit, refresh=refresh)
250
+ media = data.pop("media", [])
251
+ return {
252
+ "profile": _compact_profile(data),
253
+ "analysis": analytics.summarize_public_media(media, data.get("followers_count"), svc.settings.timezone),
254
+ }
255
+
256
+
257
+ @tool(annotations=RO)
258
+ async def compare_accounts(
259
+ usernames: Annotated[list[str], Field(min_length=1, max_length=10)],
260
+ include_me: bool = True,
261
+ media_limit: Annotated[int, Field(ge=5, le=100)] = 30,
262
+ ) -> dict[str, Any]:
263
+ """Side-by-side comparison of up to 10 professional accounts (optionally including own account, measured
264
+ the same public way): followers, posts/week, avg likes/comments/Reels views, ER by followers, main format."""
265
+ svc = service()
266
+ names = [u.lstrip("@").strip().lower() for u in usernames]
267
+ if include_me:
268
+ me = (await svc.my_username()).lower()
269
+ if me not in names:
270
+ names.insert(0, me)
271
+
272
+ rows: list[dict[str, Any]] = []
273
+ errors: dict[str, str] = {}
274
+ for name in names:
275
+ try:
276
+ data = await svc.competitor(name, media_limit=media_limit)
277
+ except Exception as exc: # one bad username must not break the whole comparison
278
+ errors[name] = str(exc)
279
+ continue
280
+ media = data.get("media", [])
281
+ summary = analytics.summarize_public_media(media, data.get("followers_count"), svc.settings.timezone)
282
+ formats = summary.get("formats") or []
283
+ rows.append(
284
+ {
285
+ "username": name,
286
+ "followers": data.get("followers_count"),
287
+ "posts_total": data.get("media_count"),
288
+ "posts_per_week": (summary.get("frequency") or {}).get("posts_per_week"),
289
+ "avg_likes": summary.get("avg_likes"),
290
+ "avg_comments": summary.get("avg_comments"),
291
+ "avg_views_reels": summary.get("avg_views_reels"),
292
+ "er_by_followers_pct": summary.get("er_by_followers_pct"),
293
+ "main_format": formats[0]["format"] if formats else None,
294
+ "top_post": (summary.get("top_posts") or [None])[0],
295
+ }
296
+ )
297
+ rows.sort(key=lambda r: r["er_by_followers_pct"] or 0, reverse=True)
298
+ return {
299
+ "media_per_account": media_limit,
300
+ "er_formula": "(avg likes + avg comments) / followers * 100",
301
+ "accounts": rows,
302
+ "errors": errors,
303
+ }
304
+
305
+
306
+ @tool(annotations=RO)
307
+ async def get_follower_history(
308
+ username: Annotated[str | None, Field(description="Username; empty = own account")] = None,
309
+ days: Annotated[int, Field(ge=2, le=730)] = 90,
310
+ ) -> dict[str, Any]:
311
+ """Follower growth from local daily snapshots (own account or any competitor fetched before).
312
+ History starts from the first time the account was fetched through this server."""
313
+ svc = service()
314
+ name = (username or await svc.my_username()).lstrip("@").lower()
315
+ result = analytics.follower_growth(svc.storage.get_snapshots(name, days))
316
+ result["username"] = name
317
+ result["tracked_accounts"] = svc.storage.tracked_usernames()
318
+ return result
319
+
320
+
321
+ # ---- tracking & history ----------------------------------------------------
322
+
323
+
324
+ @tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=True, openWorldHint=True))
325
+ async def track_competitor(
326
+ username: Annotated[str, Field(description="Professional account username, with or without @")],
327
+ note: Annotated[str | None, Field(description="Why you track it, e.g. 'AI-blog, uz'")] = None,
328
+ ) -> dict[str, Any]:
329
+ """Add an account to the tracked list (validated with one Business Discovery request).
330
+ Tracked accounts are refreshed together by update_tracked_competitors."""
331
+ svc = service()
332
+ name = username.lstrip("@").strip().lower()
333
+ data = await svc.competitor(name, media_limit=25, refresh=True)
334
+ svc.storage.track(name, note)
335
+ return {"tracked": name, "followers": data.get("followers_count"), "tracked_total": len(svc.storage.tracked())}
336
+
337
+
338
+ @tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=True))
339
+ async def untrack_competitor(username: str) -> dict[str, Any]:
340
+ """Remove an account from the tracked list. Its history stays in the database."""
341
+ svc = service()
342
+ removed = svc.storage.untrack(username.lstrip("@").strip())
343
+ return {"removed": removed, "tracked": [t["username"] for t in svc.storage.tracked()]}
344
+
345
+
346
+ @tool(annotations=RO)
347
+ async def list_tracked_competitors() -> dict[str, Any]:
348
+ """Tracked accounts with the date added and note."""
349
+ return {"tracked": service().storage.tracked()}
350
+
351
+
352
+ @tool(annotations=RO)
353
+ async def update_tracked_competitors(
354
+ media_limit: Annotated[int, Field(ge=5, le=100)] = 25,
355
+ viral_multiplier: Annotated[float, Field(ge=1.5, le=20)] = 3.0,
356
+ ) -> dict[str, Any]:
357
+ """Refresh all tracked accounts (fresh API data, 1-2 requests each): follower change over the stored history,
358
+ new posts since the previous update and viral posts (score >= multiplier × the account's median).
359
+ Run it regularly (e.g. weekly) to build history."""
360
+ svc = service()
361
+ rows: list[dict[str, Any]] = []
362
+ errors: dict[str, str] = {}
363
+ for t in svc.storage.tracked():
364
+ name = t["username"]
365
+ known = svc.storage.owner_media_ids(name)
366
+ try:
367
+ data = await svc.competitor(name, media_limit=media_limit, refresh=True)
368
+ except Exception as exc:
369
+ errors[name] = str(exc)
370
+ continue
371
+ media = data.get("media", [])
372
+ growth = analytics.follower_growth(svc.storage.get_snapshots(name, 90))
373
+ new_posts = [analytics._short(m) for m in media if known and m.get("id") not in known]
374
+ rows.append(
375
+ {
376
+ "username": name,
377
+ "followers": data.get("followers_count"),
378
+ "followers_change_90d": growth.get("change"),
379
+ "history_from": growth.get("from"),
380
+ "new_posts_since_last_update": new_posts,
381
+ "viral": analytics.viral_posts(analytics.media_since(media, 90), multiplier=viral_multiplier)["viral"],
382
+ }
383
+ )
384
+ return {"accounts": rows, "errors": errors, "note": "First update of an account cannot detect new posts yet."}
385
+
386
+
387
+ @tool(annotations=RO)
388
+ async def find_viral_posts(
389
+ username: Annotated[str | None, Field(description="Empty = all tracked accounts")] = None,
390
+ multiplier: Annotated[float, Field(ge=1.5, le=20)] = 3.0,
391
+ media_limit: Annotated[int, Field(ge=10, le=100)] = 50,
392
+ days: Annotated[int, Field(ge=7, le=730, description="Only posts from the last N days")] = 90,
393
+ ) -> dict[str, Any]:
394
+ """Posts that outperformed their account: Reels by views, other formats by likes+comments,
395
+ compared with the account's own median. Good source of proven topics and hooks."""
396
+ svc = service()
397
+ names = [username.lstrip("@").strip().lower()] if username else [t["username"] for t in svc.storage.tracked()]
398
+ if not names:
399
+ raise ToolError("No tracked accounts. Pass a username or add accounts with track_competitor.")
400
+ out: dict[str, Any] = {}
401
+ for name in names:
402
+ try:
403
+ data = await svc.competitor(name, media_limit=media_limit)
404
+ media = analytics.media_since(data.get("media", []), days)
405
+ out[name] = analytics.viral_posts(media, multiplier=multiplier)
406
+ except Exception as exc:
407
+ out[name] = {"error": str(exc)}
408
+ return {"accounts": out}
409
+
410
+
411
+ @tool(annotations=RO, structured_output=False)
412
+ async def get_competitor_post(
413
+ url: Annotated[str, Field(description="Instagram post/reel URL, e.g. https://www.instagram.com/p/DeCHjDpjuI8/")],
414
+ username: Annotated[
415
+ str | None,
416
+ Field(description="Author's username (required in practice: oEmbed auto-detection needs Meta App Review)"),
417
+ ] = None,
418
+ show_slides: Annotated[bool, Field(description="Return small previews of the slides as images")] = True,
419
+ ) -> list[Any]:
420
+ """Analyse ONE post of a professional account by its URL, without a browser: caption, hook, likes, comments,
421
+ Reels views, format, how many times better than the account's median, and all slides downloaded to
422
+ <content folder>/_refs/<username>_<code>/ (videos: cover image). With show_slides the slide previews are returned as
423
+ images (up to 20, 400 px wide) so their text and design can be read directly. Pass username: the author can't be
424
+ detected from the URL without Meta App Review. Comment texts are not available via the API."""
425
+ info = await RefFinder(service()).get(url, username)
426
+ result: list[Any] = [info]
427
+ if show_slides and info["files"]:
428
+ folder = Path(info["saved_to"])
429
+ data = await asyncio.to_thread(previews, [folder / f for f in info["files"]])
430
+ info["previews_shown"] = len(data)
431
+ if len(data) < len(info["files"]):
432
+ info["previews_not_shown"] = len(info["files"]) - len(data)
433
+ result += [Image(data=d, format="jpeg") for d in data]
434
+ return result
435
+
436
+
437
+ @tool(annotations=RO)
438
+ async def get_post_history(media_id: str) -> dict[str, Any]:
439
+ """Growth curve of one post (own or competitor) from stored snapshots: points by post age,
440
+ gain per day and status growing/plateau. Snapshots are added on every fresh fetch (max 1 per hour)."""
441
+ history = service().storage.media_history(media_id)
442
+ if history is None:
443
+ raise ToolError("No history for this media_id yet. Fetch it first via a report or get_competitor.")
444
+ return analytics.post_growth(history)
445
+
446
+
447
+ # ---- hashtags / trends -----------------------------------------------------
448
+
449
+
450
+ @tool(annotations=RO)
451
+ async def search_hashtag(
452
+ hashtag: Annotated[str, Field(description="Hashtag with or without #")],
453
+ mode: Literal["top", "recent"] = "top",
454
+ limit: Annotated[int, Field(ge=1, le=200)] = 50,
455
+ ) -> dict[str, Any]:
456
+ """Top (most popular) or recent (last 24h) public posts for a hashtag, with a trend summary:
457
+ formats, avg likes/comments, related hashtags, caption length, posting times.
458
+ Author usernames are NOT available from the API. Uses 1 of 30 weekly hashtag slots for a new tag."""
459
+ svc = service()
460
+ tag = hashtag.lstrip("#").strip().lower()
461
+ media = await svc.hashtag_media(tag, mode=mode, limit=limit)
462
+ summary = analytics.summarize_public_media(media, None, svc.settings.timezone, min_age_hours=0)
463
+ summary["related_hashtags"] = analytics.top_hashtags(media, limit=20, exclude={tag})
464
+ summary.pop("top_hashtags", None)
465
+ return {"hashtag": f"#{tag}", "mode": mode, "summary": summary, "quota": svc.storage.hashtag_quota()}
466
+
467
+
468
+ @tool(annotations=RO)
469
+ async def compare_hashtags(
470
+ hashtags: Annotated[list[str], Field(min_length=2, max_length=10)],
471
+ limit: Annotated[int, Field(ge=10, le=100)] = 50,
472
+ ) -> dict[str, Any]:
473
+ """Compare several hashtags by their top posts: avg likes/comments, dominant format, Reels share.
474
+ Each NEW hashtag uses 1 of 30 weekly slots; check get_api_status first."""
475
+ svc = service()
476
+ rows: list[dict[str, Any]] = []
477
+ errors: dict[str, str] = {}
478
+ for raw in hashtags:
479
+ tag = raw.lstrip("#").strip().lower()
480
+ try:
481
+ media = await svc.hashtag_media(tag, mode="top", limit=limit)
482
+ except Exception as exc:
483
+ errors[tag] = str(exc)
484
+ continue
485
+ s = analytics.summarize_public_media(media, None, svc.settings.timezone, min_age_hours=0)
486
+ formats = s.get("formats") or []
487
+ rows.append(
488
+ {
489
+ "hashtag": f"#{tag}",
490
+ "posts_analyzed": s.get("posts_analyzed"),
491
+ "avg_likes": s.get("avg_likes"),
492
+ "avg_comments": s.get("avg_comments"),
493
+ "median_interactions": s.get("median_interactions"),
494
+ "dominant_format": formats[0]["format"] if formats else None,
495
+ "reels_share_pct": next((f["share_pct"] for f in formats if f["format"] == "REELS"), 0.0),
496
+ "related": [h["hashtag"] for h in analytics.top_hashtags(media, 5, exclude={tag})],
497
+ }
498
+ )
499
+ rows.sort(key=lambda r: r["median_interactions"] or 0, reverse=True)
500
+ return {"hashtags": rows, "errors": errors, "quota": svc.storage.hashtag_quota()}
501
+
502
+
503
+ @tool(annotations=RO)
504
+ async def plan_hashtag_research(
505
+ hashtags: Annotated[list[str], Field(min_length=1, max_length=30)],
506
+ ) -> dict[str, Any]:
507
+ """Dry run before search_hashtag / compare_hashtags: which tags are free (queried within the last 7 days),
508
+ which need a new slot out of 30 per week, and whether they all fit. Makes no API calls."""
509
+ return service().storage.plan_hashtags(hashtags)
510
+
511
+
512
+ # ---- publishing ------------------------------------------------------------
513
+
514
+
515
+ @tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True))
516
+ async def prepare_post(
517
+ folder: Annotated[str, Field(description="Folder in the content folder (e.g. 'my-first-reel') or an absolute path in it")],
518
+ caption: Annotated[str | None, Field(description="Empty = caption.txt from the folder (text before a '---' line)")] = None,
519
+ ) -> dict[str, Any]:
520
+ """Step 1 of publishing (does NOT publish). Starts a BACKGROUND job and returns job_id immediately.
521
+ Folder with ONE .mp4/.mov -> Reel: validates the video (3 s-15 min, <=300 MB, moov at front), uploads it straight
522
+ to Meta (resumable upload; tunnel only as fallback), cover frame and feed sharing from optional reel.json
523
+ {"cover_ms": 1667, "share_to_feed": true}; images in that folder are ignored. Processing takes 1-10 min.
524
+ Otherwise 1-10 images (sorted by file name) -> photo/carousel: converts to JPEG, serves them through a temporary
525
+ Cloudflare tunnel, creates containers and waits until Meta has processed them (30-120 s).
526
+ Caption: caption.txt (text before a '---' line). Poll get_post_job(job_id) every ~15-30 s. When done it returns
527
+ creation_id and a preview: show it to the user and call publish_media only after their explicit confirmation."""
528
+ running = [j for j in JOBS.values() if j["status"] == "running"]
529
+ if running:
530
+ raise ToolError(f"Another post is being prepared (job {running[0]['job_id']}). Wait for it to finish.")
531
+ job_id = datetime.now(UTC).strftime("%H%M%S")
532
+ job: dict[str, Any] = {"job_id": job_id, "folder": folder, "status": "running", "stage": "queued",
533
+ "started_at": datetime.now(UTC).isoformat(timespec="seconds")}
534
+ JOBS[job_id] = job
535
+
536
+ def stage(name: str) -> None:
537
+ job["stage"] = name
538
+ log.info("prepare_post %s: %s", job_id, name)
539
+
540
+ async def run() -> None:
541
+ try:
542
+ async with post_queue.PUBLISH_LOCK: # never overlaps with a queued post being published
543
+ job["result"] = await Publisher(service()).prepare(folder, caption, on_stage=stage)
544
+ job["status"] = "done"
545
+ except Exception as exc: # reported through get_post_job
546
+ log.exception("prepare_post %s failed", job_id)
547
+ job["status"], job["error"] = "failed", str(exc)
548
+ finally:
549
+ job["finished_at"] = datetime.now(UTC).isoformat(timespec="seconds")
550
+
551
+ job["task"] = asyncio.create_task(run())
552
+ return {"job_id": job_id, "status": "running", "next": "Call get_post_job(job_id) in ~15-30 s."}
553
+
554
+
555
+ @tool(annotations=RO)
556
+ async def get_post_job(job_id: str) -> dict[str, Any]:
557
+ """Status of a prepare_post job: running (with current stage), done (with creation_id + preview) or failed."""
558
+ job = JOBS.get(job_id)
559
+ if job is None:
560
+ raise ToolError("Unknown job_id (jobs live only until the server restarts).")
561
+ return {k: v for k, v in job.items() if k != "task"}
562
+
563
+
564
+ @tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True))
565
+ async def publish_media(
566
+ creation_id: Annotated[str, Field(description="creation_id returned by prepare_post")],
567
+ ) -> dict[str, Any]:
568
+ """Step 2: PUBLISHES the prepared post to Instagram (public, irreversible from here).
569
+ Call ONLY after the user explicitly confirmed this exact preview."""
570
+ return await Publisher(service()).publish(creation_id)
571
+
572
+
573
+ @tool(annotations=RO)
574
+ async def list_publications(limit: Annotated[int, Field(ge=1, le=100)] = 20) -> dict[str, Any]:
575
+ """Prepared and published posts made through this server, plus the 24h publishing quota."""
576
+ svc = service()
577
+ try:
578
+ quota = await Publisher(svc).limit()
579
+ except Exception as exc:
580
+ quota = {"error": str(exc)}
581
+ return {"publications": svc.storage.publications(limit), "publishing_limit": quota}
582
+
583
+
584
+ def _queue_view(item: dict[str, Any]) -> dict[str, Any]:
585
+ tz = service().settings.timezone
586
+ keep = ("id", "folder", "media_type", "caption_head", "status", "permalink", "error")
587
+ return {**{k: item.get(k) for k in keep}, "publish_at_local": post_queue.local_str(item["publish_at"], tz),
588
+ "publish_at_utc": item["publish_at"]}
589
+
590
+
591
+ @tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False))
592
+ async def queue_post(
593
+ folder: Annotated[str, Field(description="Folder inside the content folder (same as prepare_post)")],
594
+ when: Annotated[str | None, Field(description="'next' / empty = next free slot, or 'YYYY-MM-DD HH:MM' local")] = None,
595
+ confirm: Annotated[bool, Field(description="false = preview only; true = add to the queue (after approval)")] = False,
596
+ ) -> dict[str, Any]:
597
+ """Schedule a post/carousel/Reel to be PUBLISHED AUTOMATICALLY at a time (default slots Tue/Thu/Sun 19:00 local).
598
+ Call first with confirm=false and show the preview (type, caption hook, local time) to the user. Call again with
599
+ confirm=true ONLY after the user explicitly approved this post AND this time. The server fingerprints caption and
600
+ media: if they change before the slot, the post is blocked. Late by more than 3 h (laptop asleep) = missed."""
601
+ svc = service()
602
+ tz = svc.settings.timezone
603
+ path = post_queue.resolve_folder(folder)
604
+ if active := svc.storage.queue_active_for(str(path)):
605
+ raise ToolError(f"{path.name} is already queued (id {active['id']}, {active['publish_at']}). Cancel it first.")
606
+ now = datetime.now(UTC)
607
+ taken = {datetime.fromisoformat(i["publish_at"]) for i in svc.storage.queue_list()}
608
+ try:
609
+ at = post_queue.parse_when(when, tz, now, taken)
610
+ except post_queue.QueueError as exc:
611
+ raise ToolError(str(exc)) from exc
612
+ check = await asyncio.to_thread(post_queue.validate, path)
613
+ fp = await asyncio.to_thread(post_queue.fingerprint, path)
614
+ at_iso = at.isoformat(timespec="seconds")
615
+ preview = {"folder": str(path), "publish_at_local": post_queue.local_str(at_iso, tz), "publish_at_utc": at_iso,
616
+ "fingerprint": fp, **check}
617
+ if not confirm:
618
+ return {"queued": False, "preview": preview,
619
+ "next": "Show this to the user. Call queue_post(..., confirm=true) with the SAME folder and when "
620
+ "only after they explicitly approve the post and the time."}
621
+ qid = svc.storage.queue_add(str(path), at_iso, fp, check["type"], check["caption_hook"])
622
+ return {"queued": True, "id": qid, **preview,
623
+ "note": "Published automatically while Claude Desktop runs on the laptop. Cancel with cancel_queued(id)."}
624
+
625
+
626
+ @tool(annotations=RO)
627
+ async def list_queue(include_done: bool = False) -> dict[str, Any]:
628
+ """Publishing queue: scheduled posts (and with include_done=true also published/failed/missed/blocked/cancelled),
629
+ local times, plus the next free default slots."""
630
+ svc = service()
631
+ items = svc.storage.queue_list(include_done=include_done)
632
+ taken = {datetime.fromisoformat(i["publish_at"]) for i in svc.storage.queue_list()}
633
+ tz = svc.settings.timezone
634
+ free = post_queue.next_slots(tz, datetime.now(UTC), taken, n=3)
635
+ return {"items": [_queue_view(i) for i in items],
636
+ "next_free_slots": [s.astimezone(tz).strftime("%a %Y-%m-%d %H:%M") for s in free],
637
+ "slots_rule": os.getenv("QUEUE_SLOTS") or post_queue.DEFAULT_SLOTS}
638
+
639
+
640
+ @tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=True, openWorldHint=False))
641
+ async def cancel_queued(queue_id: int) -> dict[str, Any]:
642
+ """Remove a scheduled post from the queue (nothing is deleted on Instagram)."""
643
+ st = service().storage
644
+ item = st.queue_get(queue_id)
645
+ if item is None:
646
+ raise ToolError(f"No queue item {queue_id}")
647
+ if item["status"] != "queued":
648
+ raise ToolError(f"Item {queue_id} is {item['status']}, only queued items can be cancelled")
649
+ st.queue_update(queue_id, status="cancelled")
650
+ return _queue_view(st.queue_get(queue_id))
651
+
652
+
653
+ @tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True))
654
+ async def post_reviews(
655
+ mark_reported: Annotated[bool, Field(description="False = just look, do not mark posts as reported")] = True,
656
+ ) -> dict[str, Any]:
657
+ """Post-mortems for the daily report: own posts (last 7 days) that crossed 24 h or 72 h since the last run,
658
+ with fresh insights vs the median of own mature posts of the same format (x_median), and growth 24 h -> 72 h.
659
+ Each post is reported once per stage. Explain the numbers to the user and give one concrete takeaway."""
660
+ return await reviews.post_reviews(service(), mark_reported=mark_reported)
661
+
662
+
663
+ # ---- comments ----------------------------------------------------------------
664
+
665
+
666
+ @tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True))
667
+ async def get_new_comments(
668
+ days: Annotated[int, Field(ge=1, le=90, description="Posts published in the last N days")] = 14,
669
+ only_new: Annotated[bool, Field(description="false = all unanswered, including ones shown before")] = True,
670
+ ) -> dict[str, Any]:
671
+ """Comments under own posts that you have not answered yet, including follow-ups inside threads you already
672
+ answered (new since the last call by default; they are marked as seen). For each: author, text, post, reply_to
673
+ (the top-level comment to answer in that thread). Draft replies in the comment's language and show them to the
674
+ user; send with reply_comment(reply_to) only after explicit approval. Needs instagram_manage_comments."""
675
+ return await comments.new_comments(service(), days=days, only_new=only_new)
676
+
677
+
678
+ @tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True))
679
+ async def reply_comment(
680
+ comment_id: Annotated[str, Field(description="reply_to from get_new_comments (the top-level comment of the thread)")],
681
+ message: Annotated[str, Field(description="Reply text exactly as the user approved it")],
682
+ ) -> dict[str, Any]:
683
+ """PUBLICLY replies to a comment under your post. Call ONLY after the user explicitly approved this exact text."""
684
+ return await comments.reply(service(), comment_id, message)
685
+
686
+
687
+ # ---- prompts (ready-made report scenarios) ----------------------------------
688
+
689
+
690
+ @mcp.prompt(title="Weekly Instagram report")
691
+ def weekly_report(days: str = "7") -> str:
692
+ """Weekly report: account totals, content, competitors, viral posts, 3 actions for next week."""
693
+ return f"""Make a weekly report on my Instagram for the last {days} days.
694
+
695
+ 1. get_api_status — if there is a token warning, start the report with it.
696
+ 2. get_account_insights(days={days}) — reach, views, accounts engaged, follows/unfollows.
697
+ 3. get_my_content_report(days=30) — what worked, formats, follows per reach, Reels watch time, posts still growing.
698
+ 4. update_tracked_competitors — competitors' changes and new posts; if the list is empty, say so.
699
+ 5. find_viral_posts — competitors' posts that went viral.
700
+
701
+ Format: the week's 5 key numbers in a table, 3 conclusions (each backed by a number), 3 concrete actions for next week
702
+ (format + topic + hook). Do not invent data: if a metric is missing, say it is missing.
703
+ Answer in the language I write to you in."""
704
+
705
+
706
+ @mcp.prompt(title="Competitor brief")
707
+ def competitor_brief(username: str) -> str:
708
+ """Competitor brief: profile, formats, hooks, viral posts, comparison with me."""
709
+ return f"""Analyse the competitor @{username.lstrip("@")}.
710
+
711
+ 1. get_competitor(username="{username}", media_limit=50) — profile, posting frequency, formats, best time.
712
+ 2. get_caption_insights(username="{username}") — which hooks, languages and CTAs work for them.
713
+ 3. find_viral_posts(username="{username}") — their posts that went viral.
714
+ 4. compare_accounts(usernames=["{username}"], include_me=true) — comparison with me.
715
+
716
+ Format: a "Them vs Me" table (followers, posts per week, ER by followers, main format),
717
+ 5 techniques worth adopting (with links to their posts), and 2 things I already do better.
718
+ Answer in the language I write to you in."""
719
+
720
+
721
+ @mcp.prompt(title="Data-driven content ideas")
722
+ def content_ideas(count: str = "5", language: str = "") -> str:
723
+ """Content ideas grounded in my best posts and competitors' viral posts."""
724
+ lang = f"in {language}" if language.strip() else "in the language of my recent captions"
725
+ return f"""Suggest {count} ideas for my next posts, {lang}.
726
+
727
+ 1. get_my_content_report(days=90) — my best formats and topics.
728
+ 2. get_caption_insights() — which hooks and caption lengths work for me.
729
+ 3. find_viral_posts() — what went viral for tracked competitors (skip if the list is empty).
730
+
731
+ For each idea: format (carousel/Reel), topic, a ready hook (first line), a 3–5 point outline,
732
+ and the data it is based on (a specific post or metric). No generic advice.
733
+
734
+ For Reels apply the first-second rule: frame 0 shows the result or a surprising fact, with no intro, greeting
735
+ or logo. For every Reel idea give the first-frame text (up to 6 words) and 2 hook variants for a trial Reel."""
736
+
737
+
738
+ # ---- service ---------------------------------------------------------------
739
+
740
+
741
+ @tool(annotations=RO)
742
+ async def get_api_status() -> dict[str, Any]:
743
+ """Diagnostics: token validity, days until expiry (warns 14 days ahead), last rate-limit usage headers,
744
+ hashtag quota for the rolling 7 days, cache location."""
745
+ svc = service()
746
+ try:
747
+ token = await svc.debug_token()
748
+ except Exception as exc:
749
+ token = {"error": str(exc)}
750
+ svc.storage.cache_clear(f"token:status:{svc.uid}")
751
+ disk_version = code_version()
752
+ return {
753
+ "server": {
754
+ "version": __version__,
755
+ "pid": os.getpid(),
756
+ "started_at": STARTED_AT,
757
+ "running_code_version": RUNNING_VERSION,
758
+ "disk_code_version": disk_version,
759
+ "restart_needed": disk_version != RUNNING_VERSION,
760
+ "deps_changed": deps_version() != RUNNING_DEPS,
761
+ },
762
+ "api_version": svc.settings.api_version,
763
+ "ig_user_id": svc.uid,
764
+ "token": token,
765
+ "rate_limit_usage": {
766
+ "x_app_usage": svc.client.usage.app_usage,
767
+ "x_business_use_case_usage": svc.client.usage.business_usage,
768
+ "x_ig_business_use_case_usage": svc.client.usage.instagram_usage,
769
+ "note": "Percent of quota used. Empty until the first API call in this session.",
770
+ },
771
+ "hashtag_quota": svc.storage.hashtag_quota(),
772
+ "database": str(svc.storage.path),
773
+ }
774
+
775
+
776
+ @tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=True))
777
+ async def clear_cache(
778
+ prefix: Annotated[
779
+ str | None, Field(description="me: | media: | bd: | hashtag: ; empty = everything. Snapshots are kept.")
780
+ ] = None,
781
+ ) -> dict[str, Any]:
782
+ """Drop cached API responses so next calls fetch fresh data. Follower history is not affected."""
783
+ return {"deleted": service().storage.cache_clear(prefix)}
784
+
785
+
786
+ @mcp.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=True, openWorldHint=False))
787
+ async def restart_server(
788
+ exit_code: Annotated[int, Field(ge=0, le=255, description="1 = report as crash (Desktop restarts it)")] = 1,
789
+ force: Annotated[bool, Field(description="Restart even if pyproject.toml/uv.lock changed (will likely fail)")] = False,
790
+ ) -> dict[str, Any]:
791
+ """Restart this MCP server so updated .py code is loaded, without restarting Claude Desktop.
792
+ The process exits ~1 s after replying; Claude Desktop starts it again on the next call. Use only after a code
793
+ update (get_api_status shows restart_needed=true). Wait 10-20 s before the next call.
794
+ Refuses when dependencies/version changed: then Claude Desktop must be restarted manually."""
795
+ if deps_version() != RUNNING_DEPS and not force:
796
+ raise ToolError(
797
+ "pyproject.toml or uv.lock changed since start: `uv run` would rebuild the environment and the "
798
+ "automatic restart fails. Restart Claude Desktop manually (tray -> Quit -> open)."
799
+ )
800
+ log.warning("restart_server called: exiting with code %s", exit_code)
801
+ asyncio.get_running_loop().call_later(1.0, os._exit, exit_code)
802
+ return {
803
+ "restarting": True,
804
+ "pid": os.getpid(),
805
+ "running_code_version": RUNNING_VERSION,
806
+ "disk_code_version": code_version(),
807
+ "next": "Wait 10-20 s, then call get_api_status and compare pid / running_code_version.",
808
+ }
809
+
810
+
811
+ def main() -> None:
812
+ mcp.run(transport="stdio")
813
+
814
+
815
+ if __name__ == "__main__":
816
+ main()