instamcp 2.1.1__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.
- instagram_mcp/__init__.py +806 -0
- instagram_mcp/__main__.py +5 -0
- instagram_mcp/account_pool.py +119 -0
- instagram_mcp/agents.py +908 -0
- instagram_mcp/batch_runner.py +833 -0
- instagram_mcp/cache.py +262 -0
- instagram_mcp/challenge.py +143 -0
- instagram_mcp/client.py +6527 -0
- instagram_mcp/config.py +326 -0
- instagram_mcp/cookie_manager.py +371 -0
- instagram_mcp/delay.py +99 -0
- instagram_mcp/exceptions.py +200 -0
- instagram_mcp/exporter.py +715 -0
- instagram_mcp/formatter.py +3236 -0
- instagram_mcp/media_cache.py +68 -0
- instagram_mcp/models.py +2154 -0
- instagram_mcp/monitor.py +276 -0
- instagram_mcp/oauth_manager.py +277 -0
- instagram_mcp/parser.py +1295 -0
- instagram_mcp/proxy_manager.py +533 -0
- instagram_mcp/rate_limiter.py +338 -0
- instagram_mcp/scheduler.py +232 -0
- instagram_mcp/session_manager.py +113 -0
- instagram_mcp/tools.py +5476 -0
- instamcp-2.1.1.dist-info/METADATA +338 -0
- instamcp-2.1.1.dist-info/RECORD +30 -0
- instamcp-2.1.1.dist-info/WHEEL +5 -0
- instamcp-2.1.1.dist-info/entry_points.txt +2 -0
- instamcp-2.1.1.dist-info/licenses/LICENSE +21 -0
- instamcp-2.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
"""
|
|
2
|
+
instagram_mcp — World-class Instagram data MCP server.
|
|
3
|
+
|
|
4
|
+
Architecture:
|
|
5
|
+
- 12 MCP Tools: profile, tags, feed, bulk, engagement, collab, compare, batch
|
|
6
|
+
- MCP Resources: live cache exposure for profile + feed data
|
|
7
|
+
- MCP Prompts: ready-made LLM analysis templates
|
|
8
|
+
- Smart proxy management (auto-rotation, health check, fallback)
|
|
9
|
+
- TTL cache (LRU eviction) with background cleanup
|
|
10
|
+
- Adaptive rate limiter (token-bucket + circuit breaker)
|
|
11
|
+
- Session pooling (thread-safe, curl_cffi)
|
|
12
|
+
- Full pagination (up to 200 posts via v1/feed/user + max_id)
|
|
13
|
+
- Context-aware tools: MCP-native progress reporting + logging
|
|
14
|
+
|
|
15
|
+
Transports supported:
|
|
16
|
+
- STDIO (default, for Claude Desktop / Claude Code)
|
|
17
|
+
- Streamable HTTP (set INSTAGRAM_MCP_TRANSPORT=http)
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
from instagram_mcp import create_mcp_server
|
|
21
|
+
mcp = create_mcp_server()
|
|
22
|
+
mcp.run()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import contextlib
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
from dataclasses import asdict
|
|
32
|
+
|
|
33
|
+
__version__ = "2.1.0"
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("instagram_mcp")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_mcp_server():
|
|
39
|
+
"""
|
|
40
|
+
MCP server factory — instantiates all components, registers tools,
|
|
41
|
+
resources, and prompts.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
FastMCP: Ready-to-run MCP server instance
|
|
45
|
+
"""
|
|
46
|
+
try:
|
|
47
|
+
import curl_cffi # noqa: F401
|
|
48
|
+
except ImportError:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
"curl_cffi is not installed. "
|
|
51
|
+
"Install it with: pip install curl_cffi\n"
|
|
52
|
+
"instagram_mcp requires curl_cffi for TLS fingerprint spoofing; "
|
|
53
|
+
"without it the server cannot make requests to Instagram."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
from mcp.server.fastmcp import FastMCP
|
|
57
|
+
|
|
58
|
+
from .cache import SmartCache
|
|
59
|
+
from .client import InstagramClient
|
|
60
|
+
from .config import MCPConfig
|
|
61
|
+
from .cookie_manager import CookieManager
|
|
62
|
+
from .exporter import JsonExporter
|
|
63
|
+
from .proxy_manager import ProxyManager
|
|
64
|
+
from .rate_limiter import AdaptiveRateLimiter
|
|
65
|
+
from .tools import register_tools
|
|
66
|
+
|
|
67
|
+
# ── 1. Configuration ──────────────────────────────────────────────────────
|
|
68
|
+
config = MCPConfig.from_env()
|
|
69
|
+
|
|
70
|
+
# ── 2. Components ─────────────────────────────────────────────────────────
|
|
71
|
+
cookie_manager = CookieManager(cookies_path=config.cookies_path or None)
|
|
72
|
+
try:
|
|
73
|
+
cookie_manager.load()
|
|
74
|
+
except Exception as e:
|
|
75
|
+
logger.warning("Cookie load failed: %s", e)
|
|
76
|
+
if cookie_manager.is_authenticated:
|
|
77
|
+
logger.info("instagram_mcp: authenticated session loaded from cookies.txt")
|
|
78
|
+
else:
|
|
79
|
+
logger.info("instagram_mcp: no cookies.txt — running in anonymous mode (10/14 tools available)")
|
|
80
|
+
|
|
81
|
+
cache = SmartCache(
|
|
82
|
+
max_entries=config.cache_max_entries,
|
|
83
|
+
enabled=config.cache_enabled,
|
|
84
|
+
)
|
|
85
|
+
rate_limiter = AdaptiveRateLimiter(
|
|
86
|
+
rate=config.rate_limit_rps,
|
|
87
|
+
burst=config.rate_limit_burst,
|
|
88
|
+
min_rate=config.rate_limit_min_rps,
|
|
89
|
+
backoff_factor=config.rate_backoff_factor,
|
|
90
|
+
recovery_factor=config.rate_recovery_factor,
|
|
91
|
+
circuit_breaker_threshold=config.circuit_breaker_threshold,
|
|
92
|
+
circuit_breaker_cooldown=config.circuit_breaker_cooldown,
|
|
93
|
+
request_jitter=config.request_jitter,
|
|
94
|
+
)
|
|
95
|
+
proxy_manager = ProxyManager(
|
|
96
|
+
proxy_urls=config.proxy_urls,
|
|
97
|
+
max_fails=config.proxy_max_fails,
|
|
98
|
+
cooldown_seconds=config.proxy_cooldown,
|
|
99
|
+
max_cooldown_seconds=config.proxy_max_cooldown,
|
|
100
|
+
auto_fallback=config.proxy_auto_fallback,
|
|
101
|
+
health_check_interval=config.proxy_health_interval,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# ── 3. JSON auto-exporter ─────────────────────────────────────────────────
|
|
105
|
+
exporter = JsonExporter.from_config(config)
|
|
106
|
+
if exporter.enabled:
|
|
107
|
+
logger.info(
|
|
108
|
+
"instagram_mcp: JSON auto-save enabled → %s (indent=%d)",
|
|
109
|
+
exporter.export_dir,
|
|
110
|
+
exporter.indent,
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
logger.info("instagram_mcp: JSON auto-save disabled")
|
|
114
|
+
|
|
115
|
+
# ── 4. Central client ─────────────────────────────────────────────────────
|
|
116
|
+
client = InstagramClient(
|
|
117
|
+
config=config,
|
|
118
|
+
proxy_manager=proxy_manager,
|
|
119
|
+
rate_limiter=rate_limiter,
|
|
120
|
+
cache=cache,
|
|
121
|
+
cookie_manager=cookie_manager,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# ── 5. Lifespan — all background tasks start inside the running event loop ─
|
|
125
|
+
@contextlib.asynccontextmanager
|
|
126
|
+
async def _lifespan(server):
|
|
127
|
+
async def _cache_cleanup_loop():
|
|
128
|
+
while True:
|
|
129
|
+
try:
|
|
130
|
+
await asyncio.sleep(60)
|
|
131
|
+
removed = await cache.cleanup_expired()
|
|
132
|
+
if removed:
|
|
133
|
+
logger.debug("Cache cleanup: %d expired entries removed", removed)
|
|
134
|
+
except asyncio.CancelledError:
|
|
135
|
+
break
|
|
136
|
+
except Exception as exc:
|
|
137
|
+
logger.warning("Cache cleanup error: %s", exc)
|
|
138
|
+
|
|
139
|
+
cleanup_task = asyncio.ensure_future(_cache_cleanup_loop())
|
|
140
|
+
proxy_manager.start_health_checks()
|
|
141
|
+
|
|
142
|
+
# ── Scheduler ─────────────────────────────────────────────────────────
|
|
143
|
+
from .scheduler import PostScheduler
|
|
144
|
+
_scheduler = PostScheduler(export_dir=config.export_dir)
|
|
145
|
+
_scheduler.start()
|
|
146
|
+
server._post_scheduler = _scheduler # type: ignore[attr-defined]
|
|
147
|
+
|
|
148
|
+
# ── Account Monitor ───────────────────────────────────────────────────
|
|
149
|
+
from .monitor import AccountMonitor
|
|
150
|
+
from .parser import parse_feed_items
|
|
151
|
+
|
|
152
|
+
async def _monitor_fetch(username: str, max_posts: int):
|
|
153
|
+
user = await client.fetch_user(username)
|
|
154
|
+
if user is None:
|
|
155
|
+
return []
|
|
156
|
+
profile_data = user.get("data", {}).get("user", {}) or user
|
|
157
|
+
user_id = str(profile_data.get("pk") or profile_data.get("id") or "")
|
|
158
|
+
if not user_id:
|
|
159
|
+
return []
|
|
160
|
+
items = await client.fetch_feed_items(user_id, max_posts)
|
|
161
|
+
posts = []
|
|
162
|
+
for item in items:
|
|
163
|
+
shortcode = item.get("code") or item.get("shortcode") or ""
|
|
164
|
+
posts.append({
|
|
165
|
+
"shortcode": shortcode,
|
|
166
|
+
"taken_at": item.get("taken_at", 0),
|
|
167
|
+
"likes_count": item.get("like_count", 0),
|
|
168
|
+
"caption": (item.get("caption") or {}).get("text", "") if isinstance(item.get("caption"), dict) else "",
|
|
169
|
+
})
|
|
170
|
+
return posts
|
|
171
|
+
|
|
172
|
+
_monitor = AccountMonitor(fetch_fn=_monitor_fetch)
|
|
173
|
+
_monitor.start()
|
|
174
|
+
server._account_monitor = _monitor # type: ignore[attr-defined]
|
|
175
|
+
|
|
176
|
+
# ── Session Manager ───────────────────────────────────────────────────
|
|
177
|
+
from .session_manager import SessionManager
|
|
178
|
+
_session_mgr = SessionManager.from_env(config)
|
|
179
|
+
server._session_manager = _session_mgr # type: ignore[attr-defined]
|
|
180
|
+
|
|
181
|
+
# ── OAuth Manager ─────────────────────────────────────────────────────
|
|
182
|
+
from .oauth_manager import OAuthManager
|
|
183
|
+
_oauth = OAuthManager.from_env(config.export_dir)
|
|
184
|
+
server._oauth_manager = _oauth # type: ignore[attr-defined]
|
|
185
|
+
if _oauth:
|
|
186
|
+
logger.info("instagram_mcp: OAuth manager initialized (app_id=%s…)", _oauth._app_id[:8])
|
|
187
|
+
if _oauth.needs_refresh:
|
|
188
|
+
logger.warning("instagram_mcp: OAuth token expires soon — call instagram_oauth action='refresh_token'")
|
|
189
|
+
|
|
190
|
+
logger.info(
|
|
191
|
+
"instagram_mcp v%s started | cache=%s | proxies=%d | transport=%s | sessions=%d",
|
|
192
|
+
__version__,
|
|
193
|
+
"enabled" if config.cache_enabled else "disabled",
|
|
194
|
+
len(config.proxy_urls),
|
|
195
|
+
"http" if _is_http_transport() else "stdio",
|
|
196
|
+
len(_session_mgr.list_aliases()),
|
|
197
|
+
)
|
|
198
|
+
try:
|
|
199
|
+
yield
|
|
200
|
+
finally:
|
|
201
|
+
cleanup_task.cancel()
|
|
202
|
+
with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError):
|
|
203
|
+
await asyncio.wait_for(asyncio.shield(cleanup_task), timeout=3.0)
|
|
204
|
+
await _scheduler.stop()
|
|
205
|
+
await _monitor.stop()
|
|
206
|
+
await proxy_manager.stop_health_checks()
|
|
207
|
+
await client.close()
|
|
208
|
+
logger.info("instagram_mcp v%s shutdown complete", __version__)
|
|
209
|
+
|
|
210
|
+
# ── 5. MCP server ─────────────────────────────────────────────────────────
|
|
211
|
+
import os as _os
|
|
212
|
+
_http = _os.environ.get("INSTAGRAM_MCP_TRANSPORT", "").lower() == "http"
|
|
213
|
+
_host = _os.environ.get("INSTAGRAM_MCP_HOST", "0.0.0.0")
|
|
214
|
+
_port = int(_os.environ.get("INSTAGRAM_MCP_PORT", "8000"))
|
|
215
|
+
|
|
216
|
+
_auth_status = "authenticated" if cookie_manager.is_authenticated else "anonymous (no cookies.txt)"
|
|
217
|
+
mcp = FastMCP(
|
|
218
|
+
"instagram_mcp",
|
|
219
|
+
lifespan=_lifespan,
|
|
220
|
+
host=_host if _http else "127.0.0.1",
|
|
221
|
+
port=_port if _http else 8000,
|
|
222
|
+
log_level="INFO",
|
|
223
|
+
instructions=(
|
|
224
|
+
f"Instagram data server — {_auth_status}.\n\n"
|
|
225
|
+
"AUTH TIERS:\n"
|
|
226
|
+
"• 🌐 NO LOGIN REQUIRED — 11 anonymous tools, no credentials needed.\n"
|
|
227
|
+
"• 🔐 AUTH REQUIRED — 8 tools require cookies.json/cookies.txt with a valid "
|
|
228
|
+
"Instagram session. Each tool's docstring starts with its tier marker.\n"
|
|
229
|
+
"• 🌐/🔐 AUTO-MODE — 1 tool (instagram_hashtag) works anonymously but upgrades "
|
|
230
|
+
"automatically to auth mode when cookies are present.\n\n"
|
|
231
|
+
"TOOLS (19 total):\n"
|
|
232
|
+
"• 🌐 instagram_profile — profile metadata + optional feed tags (up to 12 posts) "
|
|
233
|
+
"+ activity status. One API call covers profile, tags, mentions, dead-account check. "
|
|
234
|
+
"Set include_feed=False for fastest profile-only lookup.\n"
|
|
235
|
+
"• 🌐 instagram_feed_deep — paginated feed analysis up to 200 posts. "
|
|
236
|
+
"Builds on instagram_profile but fetches many more posts for trend analysis.\n"
|
|
237
|
+
"• 🌐 instagram_analyze_engagement — engagement rate %, content mix by type, "
|
|
238
|
+
"best posting days, top posts, top hashtags. Uses pagination.\n"
|
|
239
|
+
"• 🌐 instagram_find_collab_network — maps usertags, @mentions, co-authors, "
|
|
240
|
+
"and paid sponsors across recent posts. Use min_frequency to filter regulars.\n"
|
|
241
|
+
"• 🌐 instagram_compare_profiles — side-by-side table for 2-5 accounts in parallel.\n"
|
|
242
|
+
"• 🌐 instagram_bulk_check — fetch up to 20 accounts in parallel with status for each.\n"
|
|
243
|
+
"• 🌐 instagram_batch_scrape — HIGH-CONCURRENCY large-scale scraping up to 2000 profiles "
|
|
244
|
+
"(max_workers 1-100). TURBO MODE: profile_only=True gives 30-60x speedup (no feed fetch) — "
|
|
245
|
+
"use for bulk follower/bio scraping. stream_jsonl=True appends live to .jsonl. "
|
|
246
|
+
"Auto fail-fast at 60% error rate. Resume support: re-run with same output_file. "
|
|
247
|
+
"For 1000+ accounts pick max_workers=50-100 + profile_only=True.\n"
|
|
248
|
+
"• 🌐 instagram_server — server diagnostics (action='status') or cache management "
|
|
249
|
+
"(action='clear_cache' / action='clear_user' with username=).\n"
|
|
250
|
+
"• 🌐 instagram_post — full details for ONE post by shortcode or URL: "
|
|
251
|
+
"location (name + GPS + Google Maps link), exact timestamp, caption, hashtags, "
|
|
252
|
+
"usertags, music. Input: shortcode like 'DXjuqH9nDVE' or full post URL.\n"
|
|
253
|
+
"• 🌐/🔐 instagram_hashtag — trending/top posts for any hashtag. "
|
|
254
|
+
"AUTO-MODE: 🌐 anon gives 12 posts (no likes); 🔐 auth gives up to 300 posts "
|
|
255
|
+
"(paginated, 30/page) with full likes + plays + comments + music + tagged users. "
|
|
256
|
+
"Auth also unlocks age-gated hashtags (#swimwear, #bikini, #fitness …). "
|
|
257
|
+
"Input: tag (without #), max_posts (default 30, max 300).\n"
|
|
258
|
+
"• 🔐 instagram_search — find accounts and hashtags by keyword. "
|
|
259
|
+
"context='blended' (users+hashtags, default), 'user' (accounts only), 'hashtag' (hashtags only). "
|
|
260
|
+
"Returns: username, full name, verified, follower count, mutual follow status, "
|
|
261
|
+
"and for hashtags: post count. Auth required — returns 401 without cookies.\n"
|
|
262
|
+
"• 🔐 instagram_followers_list — recent followers of an account (~50, no pagination for others). "
|
|
263
|
+
"Returns: username, verified, private, mutual follow status. "
|
|
264
|
+
"Note: Instagram limits this to ~50 for accounts other than your own.\n"
|
|
265
|
+
"• 🔐 instagram_following_list — accounts a user follows, with full pagination (50/page). "
|
|
266
|
+
"max_users param controls total (default 200, max 1000). "
|
|
267
|
+
"Extra: is_favorite (⭐) field. Full mutual-follow detection.\n"
|
|
268
|
+
"• 🔐 instagram_post_likers — users who liked a post (~98 returned, no pagination). "
|
|
269
|
+
"Shows total like count. Full friendship_status per liker (following, followed_by, muting, blocking). "
|
|
270
|
+
"Input: shortcode or full post URL.\n"
|
|
271
|
+
"• 🔐 instagram_tagged_by — posts BY OTHERS that tag this account (passive — "
|
|
272
|
+
"they mentioned us). Tagged Tab endpoint.\n"
|
|
273
|
+
"• 🔐 instagram_reposts — content this account ACTIVELY REPOSTED from others "
|
|
274
|
+
"(endorsements — we chose to amplify them). Reposts Tab endpoint.\n"
|
|
275
|
+
"• 🔐 instagram_reels — account's OWN reels with PLAY COUNTS. "
|
|
276
|
+
"play_count is NOT available via instagram_feed_deep — only this tool exposes it. "
|
|
277
|
+
"Use for reel performance analysis and virality ranking.\n"
|
|
278
|
+
"• 🌐 instagram_post_comments — comments on a single post with per-comment like counts, "
|
|
279
|
+
"author info, threading depth, GIF detection, and language flags. "
|
|
280
|
+
"Input: shortcode or URL. sort_order='popular' for most-liked first, 'recent' for chronological. "
|
|
281
|
+
"instagram_post returns comment COUNT only — use this tool for actual comment content.\n"
|
|
282
|
+
"• 🔐 instagram_stories — fetch currently active Stories for an account. "
|
|
283
|
+
"Returns per story: media type (image/video), timestamp, expiry, duration, music, "
|
|
284
|
+
"mention stickers, linked post sticker, accessibility caption, paid partnership flag. "
|
|
285
|
+
"Stories expire after 24h — cached 2 min. Use for real-time brand monitoring.\n\n"
|
|
286
|
+
"CONTENT-FROM-OTHERS DECISION GUIDE:\n"
|
|
287
|
+
" Who appears in account's OWN posts? → instagram_find_collab_network 🌐\n"
|
|
288
|
+
" Who tagged the account in THEIR posts? → instagram_tagged_by 🔐\n"
|
|
289
|
+
" What did the account REPOST from others? → instagram_reposts 🔐\n"
|
|
290
|
+
" What's trending by hashtag? → instagram_hashtag 🌐/🔐\n"
|
|
291
|
+
" Find an account by name/keyword? → instagram_search 🔐\n"
|
|
292
|
+
" Discover related hashtags for a topic? → instagram_search context='hashtag' 🔐\n\n"
|
|
293
|
+
"Results are cached — repeated lookups are instant."
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# ── 6. Tools ──────────────────────────────────────────────────────────────
|
|
298
|
+
register_tools(mcp, client, config, exporter)
|
|
299
|
+
|
|
300
|
+
# ── 7. Resources ──────────────────────────────────────────────────────────
|
|
301
|
+
_register_resources(mcp, client, config)
|
|
302
|
+
|
|
303
|
+
# ── 8. Prompts ────────────────────────────────────────────────────────────
|
|
304
|
+
_register_prompts(mcp)
|
|
305
|
+
|
|
306
|
+
return mcp
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
310
|
+
# RESOURCES
|
|
311
|
+
# MCP Resources expose data that AI can READ directly without calling a tool.
|
|
312
|
+
# Perfect for cached profile data — no extra API call needed.
|
|
313
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
314
|
+
|
|
315
|
+
def _register_resources(mcp, client, config) -> None:
|
|
316
|
+
"""Register MCP Resources."""
|
|
317
|
+
|
|
318
|
+
from .parser import parse_profile
|
|
319
|
+
from .formatter import format_profile_json, format_feed_tags_json, format_posts_json
|
|
320
|
+
from .parser import parse_feed_tags
|
|
321
|
+
|
|
322
|
+
@mcp.resource(
|
|
323
|
+
"instagram://profile/{username}",
|
|
324
|
+
name="Instagram Profile Cache",
|
|
325
|
+
description="Cached Instagram profile data for a username. Returns JSON. Fast — no API call if cached.",
|
|
326
|
+
mime_type="application/json",
|
|
327
|
+
)
|
|
328
|
+
async def profile_resource(username: str) -> str:
|
|
329
|
+
"""Read cached profile data. If not cached, fetches from API."""
|
|
330
|
+
clean = username.strip().lstrip("@").lower()
|
|
331
|
+
if not clean:
|
|
332
|
+
return json.dumps({"error": "invalid username"})
|
|
333
|
+
|
|
334
|
+
# Try cache first
|
|
335
|
+
cached = await client.cache.get(f"user:{clean}")
|
|
336
|
+
if cached is not None:
|
|
337
|
+
try:
|
|
338
|
+
profile = parse_profile(cached, clean, config)
|
|
339
|
+
return json.dumps({
|
|
340
|
+
"cached": True,
|
|
341
|
+
"username": clean,
|
|
342
|
+
"profile": format_profile_json(profile),
|
|
343
|
+
}, ensure_ascii=False, indent=2)
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|
|
346
|
+
|
|
347
|
+
# Fetch from API
|
|
348
|
+
try:
|
|
349
|
+
user = await client.fetch_user(clean, config.cache_profile_ttl)
|
|
350
|
+
if user is None:
|
|
351
|
+
return json.dumps({"cached": False, "found": False, "username": clean})
|
|
352
|
+
profile = parse_profile(user, clean, config)
|
|
353
|
+
return json.dumps({
|
|
354
|
+
"cached": False,
|
|
355
|
+
"found": True,
|
|
356
|
+
"username": clean,
|
|
357
|
+
"profile": format_profile_json(profile),
|
|
358
|
+
}, ensure_ascii=False, indent=2)
|
|
359
|
+
except Exception as exc:
|
|
360
|
+
return json.dumps({"error": str(exc), "username": clean})
|
|
361
|
+
|
|
362
|
+
@mcp.resource(
|
|
363
|
+
"instagram://feed/{username}",
|
|
364
|
+
name="Instagram Feed Cache",
|
|
365
|
+
description="Cached recent feed data (tags, posts) for a username. Returns JSON.",
|
|
366
|
+
mime_type="application/json",
|
|
367
|
+
)
|
|
368
|
+
async def feed_resource(username: str) -> str:
|
|
369
|
+
"""Read cached feed tag data. Fetches first-page feed if not cached."""
|
|
370
|
+
clean = username.strip().lstrip("@").lower()
|
|
371
|
+
if not clean:
|
|
372
|
+
return json.dumps({"error": "invalid username"})
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
user = await client.fetch_user(clean, config.cache_tags_ttl)
|
|
376
|
+
if user is None:
|
|
377
|
+
return json.dumps({"found": False, "username": clean})
|
|
378
|
+
profile = parse_profile(user, clean, config)
|
|
379
|
+
if profile.is_private:
|
|
380
|
+
return json.dumps({"found": True, "username": clean, "is_private": True, "tags": []})
|
|
381
|
+
ft = parse_feed_tags(user, 12, 30)
|
|
382
|
+
return json.dumps({
|
|
383
|
+
"found": True,
|
|
384
|
+
"username": clean,
|
|
385
|
+
"is_private": False,
|
|
386
|
+
**format_feed_tags_json(ft),
|
|
387
|
+
"posts": format_posts_json(ft.posts),
|
|
388
|
+
}, ensure_ascii=False, indent=2)
|
|
389
|
+
except Exception as exc:
|
|
390
|
+
return json.dumps({"error": str(exc), "username": clean})
|
|
391
|
+
|
|
392
|
+
@mcp.resource(
|
|
393
|
+
"instagram://server/status",
|
|
394
|
+
name="Instagram MCP Server Status",
|
|
395
|
+
description="Live server status: cache hit rate, proxy health, rate limiter stats.",
|
|
396
|
+
mime_type="application/json",
|
|
397
|
+
)
|
|
398
|
+
async def server_status_resource() -> str:
|
|
399
|
+
"""Live server diagnostics as JSON."""
|
|
400
|
+
try:
|
|
401
|
+
from .formatter import format_diagnostics_json
|
|
402
|
+
cache_stats = await client.cache.stats()
|
|
403
|
+
proxy_statuses = await client.proxy_manager.get_all_status()
|
|
404
|
+
proxy_summary = client.proxy_manager.stats
|
|
405
|
+
rate_stats = client.rate_limiter.stats
|
|
406
|
+
return format_diagnostics_json(cache_stats, proxy_statuses, proxy_summary, rate_stats)
|
|
407
|
+
except Exception as exc:
|
|
408
|
+
return json.dumps({"error": str(exc)})
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
412
|
+
# PROMPTS
|
|
413
|
+
# MCP Prompts are reusable LLM instruction templates.
|
|
414
|
+
# Users select them from the client; variables are filled at call time.
|
|
415
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
416
|
+
|
|
417
|
+
def _register_prompts(mcp) -> None:
|
|
418
|
+
"""Register MCP Prompts — 6 workflow agents."""
|
|
419
|
+
|
|
420
|
+
# ── 1. analyze_influencer ─────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
@mcp.prompt(
|
|
423
|
+
name="analyze_influencer",
|
|
424
|
+
description=(
|
|
425
|
+
"Full influencer vetting pipeline: profile, engagement rate, collab network, "
|
|
426
|
+
"scored verdict. Use for brand partnership or sponsorship evaluation."
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
def analyze_influencer(username: str, niche: str = "", goal: str = "brand partnership") -> list:
|
|
430
|
+
niche_str = f" in the **{niche}** niche" if niche else ""
|
|
431
|
+
text = (
|
|
432
|
+
f"Vet Instagram account **@{username}**{niche_str} for: **{goal}**.\n\n"
|
|
433
|
+
"## Execution plan\n\n"
|
|
434
|
+
f"**Step 1 — Profile snapshot**\n"
|
|
435
|
+
f"Call `instagram_profile` with username={username!r}, include_feed=True, "
|
|
436
|
+
f"max_feed_posts=12, max_age_days=30, check_alive=True.\n"
|
|
437
|
+
"→ If result shows private or not_found: report that and stop.\n"
|
|
438
|
+
"→ If is_dead=True: note it and continue (score will reflect inactivity).\n\n"
|
|
439
|
+
f"**Step 2 — Engagement analysis**\n"
|
|
440
|
+
f"Call `instagram_analyze_engagement` with username={username!r}, "
|
|
441
|
+
f"max_posts=50, max_age_days=90.\n"
|
|
442
|
+
"→ Note the ER% and benchmark: Excellent ≥6%, Good 3-6%, Average 1-3%, Low <1%.\n\n"
|
|
443
|
+
f"**Step 3 — Collaboration network**\n"
|
|
444
|
+
f"Call `instagram_find_collab_network` with username={username!r}, "
|
|
445
|
+
f"max_posts=50, max_age_days=90, min_frequency=2.\n"
|
|
446
|
+
"→ Focus on: sponsor_tags (paid), recurring usertags (organic brands), "
|
|
447
|
+
"co-authors (collab posts).\n\n"
|
|
448
|
+
"## Report structure\n\n"
|
|
449
|
+
"### Profile Overview\n"
|
|
450
|
+
"Followers, following ratio, account type, verification, category, "
|
|
451
|
+
"website, city, last post age.\n\n"
|
|
452
|
+
"### Engagement Quality\n"
|
|
453
|
+
"ER% with benchmark label. Avg likes/comments. Content mix "
|
|
454
|
+
"(% reels / carousels / images). Best posting days.\n\n"
|
|
455
|
+
"### Collaboration Network\n"
|
|
456
|
+
"Top brands/people tagged (with frequency). Confirmed paid sponsors. "
|
|
457
|
+
"Co-authored posts. @mention patterns.\n\n"
|
|
458
|
+
"### Audience Signals\n"
|
|
459
|
+
"Follower/following ratio assessment. Engagement authenticity "
|
|
460
|
+
"(ER vs follower count). Activity consistency.\n\n"
|
|
461
|
+
f"### Verdict for \"{goal}\"\n"
|
|
462
|
+
"**Recommended / Conditional / Not Recommended.** "
|
|
463
|
+
"Top 3 reasons. Suggested next action."
|
|
464
|
+
)
|
|
465
|
+
return [{"role": "user", "content": {"type": "text", "text": text}}]
|
|
466
|
+
|
|
467
|
+
# ── 2. find_brand_collaborations ──────────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
@mcp.prompt(
|
|
470
|
+
name="find_brand_collaborations",
|
|
471
|
+
description=(
|
|
472
|
+
"Discover all brand deals, paid sponsors, and recurring brand mentions "
|
|
473
|
+
"from an account's recent posts. Categorises paid vs organic."
|
|
474
|
+
),
|
|
475
|
+
)
|
|
476
|
+
def find_brand_collaborations(username: str, max_posts: int = 100) -> list:
|
|
477
|
+
text = (
|
|
478
|
+
f"Map all brand relationships for **@{username}**.\n\n"
|
|
479
|
+
"## Execution plan\n\n"
|
|
480
|
+
f"**Step 1 — Collaboration network (wide scan)**\n"
|
|
481
|
+
f"Call `instagram_find_collab_network` with username={username!r}, "
|
|
482
|
+
f"max_posts={max_posts}, max_age_days=180, min_frequency=1.\n"
|
|
483
|
+
"→ Captures usertags, mentions, coauthors, sponsor_tags across all posts.\n\n"
|
|
484
|
+
f"**Step 2 — Deep feed with post details**\n"
|
|
485
|
+
f"Call `instagram_feed_deep` with username={username!r}, "
|
|
486
|
+
f"max_posts={max_posts}, max_age_days=180, include_posts_detail=True.\n"
|
|
487
|
+
"→ Gives full captions for keyword-based brand detection.\n\n"
|
|
488
|
+
"## Analysis\n\n"
|
|
489
|
+
"From the combined results, extract and categorise:\n\n"
|
|
490
|
+
"### 1. Paid Partnerships (confirmed)\n"
|
|
491
|
+
"Accounts in sponsor_tags — these are official Instagram paid partnership "
|
|
492
|
+
"disclosures. List with frequency and first appearance date.\n\n"
|
|
493
|
+
"### 2. Recurring Brand Mentions (≥2 times)\n"
|
|
494
|
+
"Brands @-mentioned in captions 2+ times. Note: organic vs likely paid "
|
|
495
|
+
"(look for #ad, #sponsored, #gifted keywords in captions).\n\n"
|
|
496
|
+
"### 3. Photo Usertags of Brands\n"
|
|
497
|
+
"Brands tagged directly in post images/videos. Ranked by frequency.\n\n"
|
|
498
|
+
"### 4. Co-authored Posts\n"
|
|
499
|
+
"Official Instagram Collab posts (coauthors list). List each brand "
|
|
500
|
+
"and number of collab posts.\n\n"
|
|
501
|
+
"### Summary Table\n"
|
|
502
|
+
"| Brand | Type | Frequency | First seen | Paid? |\n"
|
|
503
|
+
"|-------|------|-----------|------------|-------|\n"
|
|
504
|
+
"(fill from data above)\n\n"
|
|
505
|
+
"Note any brands that appear across multiple categories "
|
|
506
|
+
"(strong ongoing relationship)."
|
|
507
|
+
)
|
|
508
|
+
return [{"role": "user", "content": {"type": "text", "text": text}}]
|
|
509
|
+
|
|
510
|
+
# ── 3. competitive_analysis ───────────────────────────────────────────────
|
|
511
|
+
|
|
512
|
+
@mcp.prompt(
|
|
513
|
+
name="competitive_analysis",
|
|
514
|
+
description=(
|
|
515
|
+
"Compare 2-5 Instagram accounts for competitive intelligence. "
|
|
516
|
+
"Rankings, differentiators, engagement comparison, strategic takeaways."
|
|
517
|
+
),
|
|
518
|
+
)
|
|
519
|
+
def competitive_analysis(usernames: str, metric_focus: str = "engagement") -> list:
|
|
520
|
+
names = [u.strip().lstrip("@") for u in usernames.split(",") if u.strip()]
|
|
521
|
+
names_str = ", ".join(f"@{n}" for n in names)
|
|
522
|
+
usernames_list = str(names)
|
|
523
|
+
text = (
|
|
524
|
+
f"Competitive intelligence for: **{names_str}**\n"
|
|
525
|
+
f"Focus: **{metric_focus}**\n\n"
|
|
526
|
+
"## Execution plan\n\n"
|
|
527
|
+
f"**Step 1 — Side-by-side overview**\n"
|
|
528
|
+
f"Call `instagram_compare_profiles` with usernames={usernames_list}.\n"
|
|
529
|
+
"→ Gets followers, status, account type, category for all accounts in parallel.\n\n"
|
|
530
|
+
"**Step 2 — Engagement deep-dive (top 3 by followers)**\n"
|
|
531
|
+
"From Step 1 results, identify the top 3 accounts by follower count.\n"
|
|
532
|
+
"For each: call `instagram_analyze_engagement` (max_posts=50, max_age_days=90).\n"
|
|
533
|
+
"→ Gets ER%, content mix, best days, top posts.\n\n"
|
|
534
|
+
"**Step 3 — Collab network for the leader**\n"
|
|
535
|
+
"For the #1 account by followers:\n"
|
|
536
|
+
"Call `instagram_find_collab_network` (max_posts=50, min_frequency=2).\n"
|
|
537
|
+
"→ Reveals brand partnerships and collaboration strategy of the market leader.\n\n"
|
|
538
|
+
"## Report\n\n"
|
|
539
|
+
"### Rankings\n"
|
|
540
|
+
"| Rank | Account | Followers | ER% | Status | Why ranked here |\n"
|
|
541
|
+
"|------|---------|-----------|-----|--------|-----------------|\n\n"
|
|
542
|
+
"### Key Differentiators\n"
|
|
543
|
+
"For each account: what makes them unique? "
|
|
544
|
+
"Content style, audience size, engagement quality, posting frequency.\n\n"
|
|
545
|
+
f"### {metric_focus.capitalize()} Breakdown\n"
|
|
546
|
+
"Detailed comparison table focused on the requested metric.\n\n"
|
|
547
|
+
"### Leader's Brand Strategy\n"
|
|
548
|
+
"Who does the #1 account collaborate with? "
|
|
549
|
+
"What can competitors learn from their collab network?\n\n"
|
|
550
|
+
"### Strategic Takeaways\n"
|
|
551
|
+
"Top 3 actionable insights from this competitive landscape."
|
|
552
|
+
)
|
|
553
|
+
return [{"role": "user", "content": {"type": "text", "text": text}}]
|
|
554
|
+
|
|
555
|
+
# ── 4. account_audit ─────────────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
@mcp.prompt(
|
|
558
|
+
name="account_audit",
|
|
559
|
+
description=(
|
|
560
|
+
"Full account health audit: activity status, growth signals, content "
|
|
561
|
+
"consistency, red flags, overall verdict."
|
|
562
|
+
),
|
|
563
|
+
)
|
|
564
|
+
def account_audit(username: str, dead_threshold_days: int = 365) -> list:
|
|
565
|
+
text = (
|
|
566
|
+
f"Complete health audit of **@{username}**.\n\n"
|
|
567
|
+
"## Execution plan\n\n"
|
|
568
|
+
f"**Step 1 — Activity status**\n"
|
|
569
|
+
f"Call `instagram_profile` with username={username!r}, "
|
|
570
|
+
f"include_feed=False, check_alive=True, dead_threshold_days={dead_threshold_days}.\n"
|
|
571
|
+
"→ Fast check: active / dead / private / not_found + last_post_days.\n\n"
|
|
572
|
+
f"**Step 2 — Full profile + recent tags**\n"
|
|
573
|
+
f"Call `instagram_profile` with username={username!r}, "
|
|
574
|
+
f"include_feed=True, max_feed_posts=12, max_age_days=365, check_alive=False.\n"
|
|
575
|
+
"→ Bio, category, website, recent tags, pinned post detection.\n\n"
|
|
576
|
+
f"**Step 3 — Engagement analysis**\n"
|
|
577
|
+
f"Call `instagram_analyze_engagement` with username={username!r}, "
|
|
578
|
+
f"max_posts=50, max_age_days=180.\n"
|
|
579
|
+
"→ ER%, content mix, posting consistency, top posts.\n\n"
|
|
580
|
+
"## Audit Report\n\n"
|
|
581
|
+
"### Account Health\n"
|
|
582
|
+
f"Status (active/dead/private), last post age, "
|
|
583
|
+
f"dead threshold used: {dead_threshold_days} days.\n\n"
|
|
584
|
+
"### Growth Signals\n"
|
|
585
|
+
"Follower count tier (nano/micro/mid/macro/mega). "
|
|
586
|
+
"Following/follower ratio (healthy = ratio <1). "
|
|
587
|
+
"Posts count and account age indicators.\n\n"
|
|
588
|
+
"### Content Consistency\n"
|
|
589
|
+
"Posting frequency from ER analysis. "
|
|
590
|
+
"Content mix (% reels vs carousels vs images). "
|
|
591
|
+
"Engagement trend (stable/growing/declining based on top vs avg posts).\n\n"
|
|
592
|
+
"### Red Flags\n"
|
|
593
|
+
"Check and report if present:\n"
|
|
594
|
+
"- following >> followers (potential bot/spam)\n"
|
|
595
|
+
"- ER < 1% despite large following\n"
|
|
596
|
+
"- Gaps >60 days in posting\n"
|
|
597
|
+
"- Zero website / bio\n"
|
|
598
|
+
"- Very new account with high followers (suspicious growth)\n\n"
|
|
599
|
+
"### Overall Verdict\n"
|
|
600
|
+
"**Healthy / Needs Attention / Problematic.** "
|
|
601
|
+
"Three key reasons. One recommended action."
|
|
602
|
+
)
|
|
603
|
+
return [{"role": "user", "content": {"type": "text", "text": text}}]
|
|
604
|
+
|
|
605
|
+
# ── 5. discover_creators ─────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
@mcp.prompt(
|
|
608
|
+
name="discover_creators",
|
|
609
|
+
description=(
|
|
610
|
+
"Find similar creators by traversing the tag network of a seed account. "
|
|
611
|
+
"Returns ranked list of active public creators discovered via usertags, "
|
|
612
|
+
"mentions, and co-authored posts."
|
|
613
|
+
),
|
|
614
|
+
)
|
|
615
|
+
def discover_creators(
|
|
616
|
+
seed_username: str,
|
|
617
|
+
min_followers: int = 1000,
|
|
618
|
+
min_frequency: int = 2,
|
|
619
|
+
max_posts: int = 50,
|
|
620
|
+
) -> list:
|
|
621
|
+
text = (
|
|
622
|
+
f"Discover creators similar to **@{seed_username}** via their tag network.\n\n"
|
|
623
|
+
"## Execution plan\n\n"
|
|
624
|
+
f"**Step 1 — Seed account collab network**\n"
|
|
625
|
+
f"Call `instagram_find_collab_network` with username={seed_username!r}, "
|
|
626
|
+
f"max_posts={max_posts}, max_age_days=90, min_frequency={min_frequency}.\n"
|
|
627
|
+
"→ Extracts every person @{seed_username} tags, mentions, or co-publishes with.\n\n"
|
|
628
|
+
"**Step 2 — Profile check on discovered accounts**\n"
|
|
629
|
+
"From the collab network results, collect all unique usernames "
|
|
630
|
+
f"(usertags + mentions + coauthors). Filter to those appearing ≥{min_frequency} times.\n"
|
|
631
|
+
"For each unique username: call `instagram_profile` with "
|
|
632
|
+
"include_feed=False, check_alive=True.\n"
|
|
633
|
+
f"→ Keep only: public + active + followers ≥ {min_followers:,}.\n\n"
|
|
634
|
+
"**Step 3 — Engagement check for top 5**\n"
|
|
635
|
+
"Sort remaining creators by follower count. For the top 5:\n"
|
|
636
|
+
"Call `instagram_analyze_engagement` (max_posts=30, max_age_days=90).\n"
|
|
637
|
+
"→ Adds ER% for more accurate ranking.\n\n"
|
|
638
|
+
"## Output\n\n"
|
|
639
|
+
"### Discovered Creators\n"
|
|
640
|
+
"Ranked table:\n"
|
|
641
|
+
"| Rank | Username | How found | Frequency | Followers | ER% | Active |\n"
|
|
642
|
+
"|------|----------|-----------|-----------|-----------|-----|--------|\n"
|
|
643
|
+
"(fill from data)\n\n"
|
|
644
|
+
"How found = usertag / caption mention / co-author / paid sponsor.\n\n"
|
|
645
|
+
"### Top Picks\n"
|
|
646
|
+
"Top 3 creators with highest combined score "
|
|
647
|
+
"(frequency × followers × engagement). Brief profile note for each.\n\n"
|
|
648
|
+
"### Network Insights\n"
|
|
649
|
+
"What type of creators does @{seed_username} engage with most? "
|
|
650
|
+
"Any recurring brand accounts vs personal creators? "
|
|
651
|
+
"Any unexpected discoveries?"
|
|
652
|
+
)
|
|
653
|
+
return [{"role": "user", "content": {"type": "text", "text": text}}]
|
|
654
|
+
|
|
655
|
+
# ── 6. validate_prospect_list ─────────────────────────────────────────────
|
|
656
|
+
|
|
657
|
+
@mcp.prompt(
|
|
658
|
+
name="validate_prospect_list",
|
|
659
|
+
description=(
|
|
660
|
+
"Score and rank a list of Instagram accounts for outreach qualification. "
|
|
661
|
+
"Filters out dead/private/not_found, scores remaining by "
|
|
662
|
+
"followers + engagement + activity, returns a ranked shortlist."
|
|
663
|
+
),
|
|
664
|
+
)
|
|
665
|
+
def validate_prospect_list(
|
|
666
|
+
usernames: str,
|
|
667
|
+
min_followers: int = 1000,
|
|
668
|
+
goal: str = "influencer outreach",
|
|
669
|
+
) -> list:
|
|
670
|
+
names = [u.strip().lstrip("@") for u in usernames.split(",") if u.strip()]
|
|
671
|
+
names_list = str(names)
|
|
672
|
+
text = (
|
|
673
|
+
f"Validate and rank prospects for: **{goal}**\n"
|
|
674
|
+
f"Accounts to check: {', '.join(f'@{n}' for n in names)}\n\n"
|
|
675
|
+
"## Execution plan\n\n"
|
|
676
|
+
f"**Step 1 — Bulk status check**\n"
|
|
677
|
+
f"Call `instagram_bulk_check` with usernames={names_list}, concurrency=5.\n"
|
|
678
|
+
"→ Quick parallel check: found/not_found, followers, dead/private flags.\n\n"
|
|
679
|
+
"**Step 2 — Filter disqualified accounts**\n"
|
|
680
|
+
"Remove from the list:\n"
|
|
681
|
+
f"- not_found accounts\n"
|
|
682
|
+
"- private accounts (can't verify content quality)\n"
|
|
683
|
+
"- dead accounts (no recent posts)\n"
|
|
684
|
+
f"- followers < {min_followers:,}\n\n"
|
|
685
|
+
"**Step 3 — Engagement for remaining prospects**\n"
|
|
686
|
+
"For each account that passed Step 2 filters:\n"
|
|
687
|
+
"Call `instagram_analyze_engagement` (max_posts=30, max_age_days=90).\n"
|
|
688
|
+
"→ Gets ER% for accurate scoring.\n\n"
|
|
689
|
+
"**Step 4 — Score each account**\n"
|
|
690
|
+
"Score formula (0-100):\n"
|
|
691
|
+
"- Engagement Rate (0-40): ≥6%→40, ≥3%→30, ≥1%→15, <1%→0\n"
|
|
692
|
+
"- Followers (0-30): log scale, 10M→30, 1M→21, 100K→14, 10K→7\n"
|
|
693
|
+
"- Activity (0-20): ≤7d→20, ≤30d→15, ≤90d→8, ≤365d→3\n"
|
|
694
|
+
"- Quality (0-10): verified→5, business→2, highlights→2, reels→1\n\n"
|
|
695
|
+
"## Output\n\n"
|
|
696
|
+
"### Qualified Prospects (ranked)\n"
|
|
697
|
+
"| Rank | Username | Followers | ER% | Score | Last post | Category |\n"
|
|
698
|
+
"|------|----------|-----------|-----|-------|-----------|----------|\n\n"
|
|
699
|
+
"### Disqualified\n"
|
|
700
|
+
"| Username | Reason |\n"
|
|
701
|
+
"|----------|--------|\n\n"
|
|
702
|
+
"### Recommendation\n"
|
|
703
|
+
f"Top 3 accounts best suited for {goal}. "
|
|
704
|
+
"One sentence per account explaining why."
|
|
705
|
+
)
|
|
706
|
+
return [{"role": "user", "content": {"type": "text", "text": text}}]
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
710
|
+
# TRANSPORT HELPER
|
|
711
|
+
# ═════════════════════════════════════════════════════════════════════════════
|
|
712
|
+
|
|
713
|
+
def _is_http_transport() -> bool:
|
|
714
|
+
"""Check if HTTP transport is requested via env var."""
|
|
715
|
+
import os
|
|
716
|
+
return os.environ.get("INSTAGRAM_MCP_TRANSPORT", "").lower() == "http"
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def run_server() -> None:
|
|
720
|
+
"""
|
|
721
|
+
Entry point — run the MCP server.
|
|
722
|
+
|
|
723
|
+
Transport selection:
|
|
724
|
+
- STDIO (default): for Claude Desktop / Claude Code
|
|
725
|
+
- HTTP: set INSTAGRAM_MCP_TRANSPORT=http
|
|
726
|
+
optionally set INSTAGRAM_MCP_HOST and INSTAGRAM_MCP_PORT
|
|
727
|
+
"""
|
|
728
|
+
import os
|
|
729
|
+
import sys
|
|
730
|
+
import logging as _logging
|
|
731
|
+
|
|
732
|
+
# curl_cffi AsyncSession requires SelectorEventLoop on Windows;
|
|
733
|
+
# ProactorEventLoop (the default since Python 3.8) is incompatible.
|
|
734
|
+
if sys.platform == "win32":
|
|
735
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
736
|
+
|
|
737
|
+
_logging.basicConfig(
|
|
738
|
+
level=_logging.INFO,
|
|
739
|
+
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
|
|
740
|
+
stream=sys.stderr,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
# uvloop — drop-in replacement giving +30-70% async throughput on Linux/macOS.
|
|
744
|
+
# Optional dependency; cleanly falls back if missing or on Windows.
|
|
745
|
+
if sys.platform != "win32":
|
|
746
|
+
try:
|
|
747
|
+
import uvloop
|
|
748
|
+
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
|
749
|
+
logger.info("uvloop enabled — async event loop policy upgraded")
|
|
750
|
+
except ImportError:
|
|
751
|
+
logger.debug("uvloop not installed — falling back to default asyncio loop")
|
|
752
|
+
|
|
753
|
+
mcp = create_mcp_server()
|
|
754
|
+
|
|
755
|
+
if _is_http_transport():
|
|
756
|
+
import uvicorn
|
|
757
|
+
host = os.environ.get("INSTAGRAM_MCP_HOST", "127.0.0.1")
|
|
758
|
+
port = int(os.environ.get("INSTAGRAM_MCP_PORT", "8765"))
|
|
759
|
+
logger.info("Starting HTTP transport on %s:%d", host, port)
|
|
760
|
+
app = mcp.streamable_http_app()
|
|
761
|
+
uvicorn.run(app, host=host, port=port)
|
|
762
|
+
else:
|
|
763
|
+
mcp.run(transport="stdio")
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# Public exports
|
|
767
|
+
from .batch_runner import BatchConfig, BatchRunner, BatchStats
|
|
768
|
+
from .agents import (
|
|
769
|
+
AccountHealthAgent,
|
|
770
|
+
BulkScoringAgent,
|
|
771
|
+
ContentAuditAgent,
|
|
772
|
+
ContentAuditReport,
|
|
773
|
+
CreatorDiscoveryAgent,
|
|
774
|
+
InfluencerVettingAgent,
|
|
775
|
+
ScoredAccount,
|
|
776
|
+
VettingResult,
|
|
777
|
+
HealthReport,
|
|
778
|
+
DiscoveredCreator,
|
|
779
|
+
compute_account_score,
|
|
780
|
+
compute_er,
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
__all__ = [
|
|
784
|
+
"create_mcp_server",
|
|
785
|
+
"run_server",
|
|
786
|
+
# Batch
|
|
787
|
+
"BatchConfig",
|
|
788
|
+
"BatchRunner",
|
|
789
|
+
"BatchStats",
|
|
790
|
+
# Agents
|
|
791
|
+
"InfluencerVettingAgent",
|
|
792
|
+
"AccountHealthAgent",
|
|
793
|
+
"CreatorDiscoveryAgent",
|
|
794
|
+
"BulkScoringAgent",
|
|
795
|
+
"ContentAuditAgent",
|
|
796
|
+
# Agent result types
|
|
797
|
+
"VettingResult",
|
|
798
|
+
"HealthReport",
|
|
799
|
+
"DiscoveredCreator",
|
|
800
|
+
"ScoredAccount",
|
|
801
|
+
"ContentAuditReport",
|
|
802
|
+
# Scoring helpers
|
|
803
|
+
"compute_account_score",
|
|
804
|
+
"compute_er",
|
|
805
|
+
"__version__",
|
|
806
|
+
]
|