viralhunt-mcp 0.1.2 → 0.2.0
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.
- package/README.md +80 -52
- package/index.js +352 -127
- package/package.json +32 -32
package/README.md
CHANGED
|
@@ -1,52 +1,80 @@
|
|
|
1
|
-
# ViralHunt MCP Server
|
|
2
|
-
|
|
3
|
-
An [MCP](https://modelcontextprotocol.io) server for **[ViralHunt.io](https://viralhunt.io)** —
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
1
|
+
# ViralHunt MCP Server
|
|
2
|
+
|
|
3
|
+
An [MCP](https://modelcontextprotocol.io) server for **[ViralHunt.io](https://viralhunt.io)** — the
|
|
4
|
+
all-in-one platform to **discover what's trending, curate it with your team, and publish everywhere**.
|
|
5
|
+
A BuzzSumo alternative with a built-in cross-network scheduler and an API that AI agents can drive
|
|
6
|
+
end to end.
|
|
7
|
+
|
|
8
|
+
Get a free token: **https://viralhunt.io** → Account → API Access (7-day trial, no card). Full API
|
|
9
|
+
docs: **https://viralhunt.io/api**.
|
|
10
|
+
|
|
11
|
+
## What an agent can do through this MCP server
|
|
12
|
+
|
|
13
|
+
These are the tools this server exposes (they drive the full **discover → time it → publish → verify →
|
|
14
|
+
correct** loop). Every number comes with its sample size and time window, so an agent can say how
|
|
15
|
+
much to trust it:
|
|
16
|
+
|
|
17
|
+
| Tool | What it does |
|
|
18
|
+
|------|--------------|
|
|
19
|
+
| `viralhunt_trending` | **Find topics** — what's going viral on TikTok, Instagram, X, Facebook, Pinterest, Bluesky, Douyin, Reddit, Mastodon, Tumblr, Hacker News and news RSS, ranked by viral score, each post with `growth_24h` (how much it moved between our two most distant readings) |
|
|
20
|
+
| `viralhunt_best_time` | **When to post** — best weekday + hour per network from the posts that went viral there (365-day sample, hit rate, sample size, your time zone, optional niche keyword) |
|
|
21
|
+
| `viralhunt_top_hashtags` | **Which hashtags** — top tags per network or across all, by engagement, posts or per-post; one tag's breakdown by network |
|
|
22
|
+
| `viralhunt_trending_sounds` | **Which sound** — trending audio on TikTok, Instagram Reels and Douyin, cross-network sounds first, with the posts that used it |
|
|
23
|
+
| `viralhunt_best_communities` | **Where to post** — best subreddits (peak per 1,000 members, timing, top posts, similar) and Bluesky custom feeds for a topic |
|
|
24
|
+
| `viralhunt_targets` | List your **brands/projects** and the connected accounts you can post to |
|
|
25
|
+
| `viralhunt_schedule` | **Publish now or schedule** a post (text + media) across your connected accounts |
|
|
26
|
+
| `viralhunt_get_post` | Check a post's status + per-network permalinks |
|
|
27
|
+
| `viralhunt_update_post` | Edit a still-scheduled post (body / media / networks / time) |
|
|
28
|
+
| `viralhunt_cancel_post` | Cancel the not-yet-published targets of a scheduled post |
|
|
29
|
+
| `viralhunt_list_templates` | Browse the **content template** library (on-brand layouts you fill) |
|
|
30
|
+
| `viralhunt_get_template` | Get one template's full spec (html + css + variable manifest) to render |
|
|
31
|
+
| `viralhunt_upsert_template` | **Author or edit a template** — editing a curated one clones it into your copy (owner/admin) |
|
|
32
|
+
| `viralhunt_assign_template` | Assign a template to a project so its agents can use it (owner/admin) |
|
|
33
|
+
| `viralhunt_board_context` | The team's **Editorial Board** in one call: members (with ids), columns, categories |
|
|
34
|
+
| `viralhunt_create_card` | **Hand a post to a teammate** — create a kanban card (URL metadata auto-filled, assignee, priority, due date, category) |
|
|
35
|
+
| `viralhunt_move_card` | Move a card to another column (the `is_done` column completes it) |
|
|
36
|
+
| `viralhunt_my_cards` | The cards assigned to you (when the token belongs to an agent member of the team) |
|
|
37
|
+
| `viralhunt_card_comments` | Read or add comments on a card (notifies the assignee, mirrors to team chat) |
|
|
38
|
+
|
|
39
|
+
Guardrails baked in: it won't post to the wrong brand, only schedules in the future, and is told not to
|
|
40
|
+
repost fake news / copyrighted media / spam.
|
|
41
|
+
|
|
42
|
+
## What ViralHunt the platform does (beyond this MCP)
|
|
43
|
+
|
|
44
|
+
ViralHunt is a full product; the tools above are the agent-drivable slice. The rest lives in the app
|
|
45
|
+
(and some via the [REST API](https://viralhunt.io/api)):
|
|
46
|
+
|
|
47
|
+
- **Trending Radar** — a live cross-network feed of viral content by niche and time window.
|
|
48
|
+
- **Cross-network publishing & scheduling** — schedule/publish to **up to 11 networks** (Instagram,
|
|
49
|
+
Facebook, TikTok, X, LinkedIn, YouTube, Pinterest, Threads, Bluesky, Telegram, Google Business),
|
|
50
|
+
per brand.
|
|
51
|
+
- **Editorial Board (team)** — a kanban to curate before publishing: columns, categories, assignments,
|
|
52
|
+
comments and team chat, so a team manages what goes out. (Cards/columns/comments are in the REST API.)
|
|
53
|
+
- **Auto-DM (comment → DM automation)** — when someone comments your keyword on Instagram, ViralHunt
|
|
54
|
+
auto-replies and sends them a DM — like ManyChat, but flat pricing, unlimited contacts, and a monthly
|
|
55
|
+
cap that resets. Configured in the app (runs via webhooks); Facebook next.
|
|
56
|
+
- **Reports** — what each brand/network actually did: posts, reach, DMs sent, replies, top content.
|
|
57
|
+
|
|
58
|
+
> Note: Auto-DM and Reports are **product features**, not tools this MCP exposes today. The editorial
|
|
59
|
+
> board is exposed (context, cards, moves, comments) so an agent can hand content to a team instead
|
|
60
|
+
> of publishing it directly.
|
|
61
|
+
|
|
62
|
+
## Install (Claude Desktop / Cline)
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcpServers": {
|
|
67
|
+
"viralhunt": {
|
|
68
|
+
"command": "npx",
|
|
69
|
+
"args": ["-y", "viralhunt-mcp"],
|
|
70
|
+
"env": { "VIRALHUNT_API_KEY": "vhk_your_token_here" }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Config: `VIRALHUNT_API_KEY` (your `vhk_…` token, required) and `VIRALHUNT_BASE_URL` (optional, defaults
|
|
77
|
+
to the production API). Run locally: `VIRALHUNT_API_KEY=vhk_... npx viralhunt-mcp` (Node ≥ 18).
|
|
78
|
+
|
|
79
|
+
Listed on the [Official MCP Registry](https://registry.modelcontextprotocol.io) as
|
|
80
|
+
`io.github.rodvan/viralhunt-mcp`. License: MIT.
|
package/index.js
CHANGED
|
@@ -1,127 +1,352 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* ViralHunt MCP server — exposes the ViralHunt.io public API v1 as MCP tools so any MCP client
|
|
4
|
-
* (Claude Desktop, Cline, etc.) can run the full loop: find trending content → publish/schedule →
|
|
5
|
-
* verify → edit/cancel.
|
|
6
|
-
*
|
|
7
|
-
* Config via environment:
|
|
8
|
-
* VIRALHUNT_API_KEY required — a personal token (vhk_…) from viralhunt.io → Account → API Access
|
|
9
|
-
* VIRALHUNT_BASE_URL optional — defaults to https://viralhunt.io/tool/api/v1
|
|
10
|
-
*
|
|
11
|
-
* No token is stored here; it's read from the env and sent as a Bearer header per request.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
-
import { z } from "zod";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
networks:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ViralHunt MCP server — exposes the ViralHunt.io public API v1 as MCP tools so any MCP client
|
|
4
|
+
* (Claude Desktop, Cline, etc.) can run the full loop: find trending content → publish/schedule →
|
|
5
|
+
* verify → edit/cancel.
|
|
6
|
+
*
|
|
7
|
+
* Config via environment:
|
|
8
|
+
* VIRALHUNT_API_KEY required — a personal token (vhk_…) from viralhunt.io → Account → API Access
|
|
9
|
+
* VIRALHUNT_BASE_URL optional — defaults to https://viralhunt.io/tool/api/v1
|
|
10
|
+
*
|
|
11
|
+
* No token is stored here; it's read from the env and sent as a Bearer header per request.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
|
|
21
|
+
// One version, read from package.json: the server announced 0.1.0 while the package was at 0.1.5
|
|
22
|
+
// for several releases, so directories showed a version that matched nothing they could install.
|
|
23
|
+
const PKG = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "package.json"), "utf8"));
|
|
24
|
+
|
|
25
|
+
const API_KEY = process.env.VIRALHUNT_API_KEY || "";
|
|
26
|
+
const BASE_URL = (process.env.VIRALHUNT_BASE_URL || "https://viralhunt.io/tool/api/v1").replace(/\/+$/, "");
|
|
27
|
+
|
|
28
|
+
/** Call the ViralHunt API. Returns parsed JSON; throws with the API's error message on failure. */
|
|
29
|
+
async function vh(path, { method = "GET", query = null, body = null } = {}) {
|
|
30
|
+
if (!API_KEY) throw new Error("VIRALHUNT_API_KEY is not set. Get a token at viralhunt.io → Account → API Access.");
|
|
31
|
+
let url = BASE_URL + path;
|
|
32
|
+
if (query) {
|
|
33
|
+
const qs = new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== "")).toString();
|
|
34
|
+
if (qs) url += (url.includes("?") ? "&" : "?") + qs;
|
|
35
|
+
}
|
|
36
|
+
const opts = { method, headers: { Authorization: `Bearer ${API_KEY}`, Accept: "application/json" } };
|
|
37
|
+
if (body !== null) { opts.headers["Content-Type"] = "application/json"; opts.body = JSON.stringify(body); }
|
|
38
|
+
|
|
39
|
+
const res = await fetch(url, opts);
|
|
40
|
+
const text = await res.text();
|
|
41
|
+
let json;
|
|
42
|
+
try { json = JSON.parse(text); } catch { throw new Error(`ViralHunt returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`); }
|
|
43
|
+
if (!res.ok || json?.success === false) {
|
|
44
|
+
const e = json?.error || {};
|
|
45
|
+
throw new Error(`ViralHunt API ${res.status} ${e.code || ""}: ${e.message || text.slice(0, 200)}`.trim());
|
|
46
|
+
}
|
|
47
|
+
return json.data ?? json;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Wrap a handler so any error is returned as a clean MCP error result (isError). */
|
|
51
|
+
function tool(server, name, description, schema, handler) {
|
|
52
|
+
server.tool(name, description, schema, async (args) => {
|
|
53
|
+
try {
|
|
54
|
+
const data = await handler(args);
|
|
55
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return { content: [{ type: "text", text: String(err?.message || err) }], isError: true };
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const server = new McpServer({ name: "viralhunt", version: PKG.version });
|
|
63
|
+
|
|
64
|
+
// ── Discover trending content ──
|
|
65
|
+
tool(server, "viralhunt_trending",
|
|
66
|
+
[
|
|
67
|
+
"Find trending/viral posts on a social network, ranked by viral score (engagement velocity).",
|
|
68
|
+
"Sources: tiktok, instagram, x, facebook, pinterest, bluesky, douyin, reddit, mastodon, tumblr, hackernews, and rss (news articles",
|
|
69
|
+
"from 230 feeds plus articles discovered through social links, each with Facebook/Reddit/Bluesky/Hacker News/comment signals).",
|
|
70
|
+
"Each post carries growth_24h when we hold at least two readings of it: {from, to, delta, percent, hours, measured_at, samples}.",
|
|
71
|
+
"Read it carefully: growth_24h null means 'we cannot say', NOT zero (Facebook and Pinterest never carry it); `hours` is the REAL",
|
|
72
|
+
"window between the two readings and is not always 24, so quote it; `percent` is null when the post started from zero while",
|
|
73
|
+
"`delta` still holds the absolute change. A high delta over few hours is what 'going viral right now' looks like.",
|
|
74
|
+
].join(" "),
|
|
75
|
+
{
|
|
76
|
+
source: z.enum(["tiktok", "instagram", "x", "facebook", "pinterest", "bluesky", "douyin", "reddit", "mastodon", "tumblr", "hackernews", "rss"]).describe("Which network to pull trending from."),
|
|
77
|
+
sort: z.string().optional().describe("viral (default), engagement, newest, oldest, plus per network: most_liked, most_viewed, most_commented, most_retweeted, most_reposted, most_saved, most_upvoted (reddit), most_boosted (mastodon), most_noted (tumblr), most_points (hackernews); rss: trending, engagement, growth, bluesky, mentions, coverage, hn, comments."),
|
|
78
|
+
time_range: z.enum(["6h", "12h", "24h", "7d", "30d", "3m", "all"]).optional().describe("Time window on the post's own publish date (default 7d)."),
|
|
79
|
+
keyword: z.string().optional().describe("Filter by keyword/niche (matched in title, text, hashtags or author)."),
|
|
80
|
+
subreddit: z.string().optional().describe("reddit only: restrict to one subreddit (with or without r/)."),
|
|
81
|
+
min_engagement: z.number().int().min(0).optional().describe("Minimum engagement on the network's main metric."),
|
|
82
|
+
per_page: z.number().int().min(1).max(100).optional().describe("How many results (max 100)."),
|
|
83
|
+
page: z.number().int().min(1).optional().describe("Page number for more results."),
|
|
84
|
+
},
|
|
85
|
+
(a) => vh("/trending.php", { query: { source: a.source, sort: a.sort || "viral", time_range: a.time_range || "7d", keyword: a.keyword, subreddit: a.subreddit, min_engagement: a.min_engagement, per_page: a.per_page || 20, page: a.page } })
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// ── Where can I publish (projects + connected accounts) ──
|
|
89
|
+
tool(server, "viralhunt_targets",
|
|
90
|
+
"List the user's projects/brands and the connected social accounts you can publish to in each.",
|
|
91
|
+
{ project: z.string().optional().describe("Project name to scope the accounts to (optional).") },
|
|
92
|
+
(a) => vh("/schedule.php", { query: { action: "targets", project: a.project } })
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// ── Publish now or schedule ──
|
|
96
|
+
tool(server, "viralhunt_schedule",
|
|
97
|
+
"Publish now or schedule a post (text + media URLs) to a project's connected accounts. Verify content before posting — never publish fake news, copyrighted media, or spam.",
|
|
98
|
+
{
|
|
99
|
+
project: z.string().optional().describe("Project name — REQUIRED if the org has more than one project."),
|
|
100
|
+
project_id: z.number().int().optional().describe("Project id — alternative to project name."),
|
|
101
|
+
body: z.string().optional().describe("Caption/text. Optional if media is provided."),
|
|
102
|
+
media: z.array(z.string()).optional().describe("Public image/video URLs."),
|
|
103
|
+
networks: z.array(z.string()).optional().describe('Post to every account on these networks, e.g. ["instagram","facebook"].'),
|
|
104
|
+
target_account_ids: z.array(z.number().int()).optional().describe("Exact account ids (from viralhunt_targets). Omit = all accounts in the project."),
|
|
105
|
+
scheduled_at: z.string().optional().describe("ISO-8601 UTC, e.g. 2026-08-01T15:30:00Z. Omit = publish now."),
|
|
106
|
+
first_comment: z.string().optional().describe("Optional first comment (link-in-comments)."),
|
|
107
|
+
},
|
|
108
|
+
(a) => vh("/schedule.php", { method: "POST", query: { action: "create" }, body: {
|
|
109
|
+
project: a.project, project_id: a.project_id, body: a.body, media: a.media,
|
|
110
|
+
networks: a.networks, target_account_ids: a.target_account_ids, scheduled_at: a.scheduled_at, first_comment: a.first_comment,
|
|
111
|
+
} })
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// ── Check status ──
|
|
115
|
+
tool(server, "viralhunt_get_post",
|
|
116
|
+
"Get the current status and per-network results (incl. permalinks) of a post you created.",
|
|
117
|
+
{ id: z.number().int().describe("The post id returned by viralhunt_schedule.") },
|
|
118
|
+
(a) => vh("/schedule.php", { query: { action: "get", id: a.id } })
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// ── Edit a scheduled post ──
|
|
122
|
+
tool(server, "viralhunt_update_post",
|
|
123
|
+
"Edit a still-scheduled post. Only works while status is 'scheduled' and >5 min before publish. Only the fields you send change; the project cannot be changed. Check status with viralhunt_get_post first; on a 409 error re-fetch instead of retrying.",
|
|
124
|
+
{
|
|
125
|
+
id: z.number().int().describe("The post id."),
|
|
126
|
+
body: z.string().optional().describe("New caption."),
|
|
127
|
+
media: z.array(z.string()).optional().describe("New media URLs (replaces the set)."),
|
|
128
|
+
networks: z.array(z.string()).optional().describe("New network set (adds/removes targets)."),
|
|
129
|
+
target_account_ids: z.array(z.number().int()).optional().describe("New exact account-id set."),
|
|
130
|
+
scheduled_at: z.string().optional().describe("New ISO-8601 UTC time (must be in the future)."),
|
|
131
|
+
},
|
|
132
|
+
(a) => vh("/schedule.php", { method: "POST", query: { action: "update" }, body: {
|
|
133
|
+
id: a.id, body: a.body, media: a.media, networks: a.networks, target_account_ids: a.target_account_ids, scheduled_at: a.scheduled_at,
|
|
134
|
+
} })
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// ── Content templates: browse the library ──
|
|
138
|
+
tool(server, "viralhunt_list_templates",
|
|
139
|
+
"List ViralHunt content templates (layouts you fill and render yourself). Lean by design — no html/css — so use viralhunt_get_template for the one you'll actually render.",
|
|
140
|
+
{
|
|
141
|
+
category: z.string().optional().describe("image | quote | video | top3 | …"),
|
|
142
|
+
media_type: z.enum(["image", "video"]).optional().describe("Filter by what it renders to."),
|
|
143
|
+
network: z.string().optional().describe("Only templates suited to this network."),
|
|
144
|
+
project_id: z.number().int().optional().describe("With assigned=true, the project whose allowed templates you want."),
|
|
145
|
+
assigned: z.boolean().optional().describe("true = only the templates assigned to project_id."),
|
|
146
|
+
q: z.string().optional().describe("Search name/description."),
|
|
147
|
+
},
|
|
148
|
+
(a) => vh("/templates.php", { query: { category: a.category, media_type: a.media_type, network: a.network, q: a.q, assigned: a.assigned ? 1 : undefined, project_id: a.project_id } })
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// ── Content templates: the full layout to render ──
|
|
152
|
+
tool(server, "viralhunt_get_template",
|
|
153
|
+
"Get one template's full spec: html, css, the variable manifest (with hard rules per variable), formats, palette tokens, embedded fonts and render_tech. Fill every {{variable}}, then render with headless Chrome at the chosen format's size and WAIT for [data-vh-ready='1'] before screenshotting .vh-card.",
|
|
154
|
+
{
|
|
155
|
+
slug: z.string().optional().describe("Template slug, e.g. vh-image-card."),
|
|
156
|
+
id: z.number().int().optional().describe("Template id — alternative to slug."),
|
|
157
|
+
},
|
|
158
|
+
(a) => vh("/templates.php", { query: { slug: a.slug, id: a.id } })
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// ── Content templates: author or edit one ──
|
|
162
|
+
tool(server, "viralhunt_upsert_template",
|
|
163
|
+
"Create or edit a content template (layout HTML/CSS + variable manifest). Editing a curated/global template CLONES it into the org's own copy — the global is never modified. Every {{placeholder}} in the html must be declared in variables, or the call is rejected. Owner/admin only. On update you may send only the fields that change.",
|
|
164
|
+
{
|
|
165
|
+
action: z.enum(["create", "update"]).optional().describe("create (default) or update."),
|
|
166
|
+
id: z.number().int().optional().describe("On update: the template id to edit."),
|
|
167
|
+
slug_ref: z.string().optional().describe("On update: the slug to edit, if you don't have the id."),
|
|
168
|
+
slug: z.string().describe("Stable kebab-case id for the template, e.g. my-quote-card."),
|
|
169
|
+
name: z.string().optional().describe("Human-readable name."),
|
|
170
|
+
category: z.string().optional().describe("image | quote | video | top3 | …"),
|
|
171
|
+
media_type: z.enum(["image", "video"]).optional(),
|
|
172
|
+
network: z.string().optional().describe("Network it suits, or omit for any."),
|
|
173
|
+
aspect: z.string().optional().describe("Default format's aspect, e.g. 4:5."),
|
|
174
|
+
canvas_w: z.number().int().optional(),
|
|
175
|
+
canvas_h: z.number().int().optional(),
|
|
176
|
+
formats: z.array(z.object({}).passthrough()).optional().describe("[{name,w,h,aspect?,networks?,default?}] — the sizes this one layout ships."),
|
|
177
|
+
description: z.string().optional(),
|
|
178
|
+
html: z.string().optional().describe("Layout with {{variable}} placeholders. Max 256KB."),
|
|
179
|
+
css: z.string().optional().describe("Styles; reference palette tokens via CSS vars. Max 256KB."),
|
|
180
|
+
palette: z.object({}).passthrough().optional().describe("Design tokens → hex, e.g. {\"cyan\":\"#1edbee\"}."),
|
|
181
|
+
fonts: z.array(z.object({}).passthrough()).optional().describe("[{family,weight,style,url_woff2}] — fonts must travel with the template."),
|
|
182
|
+
variables: z.array(z.object({}).passthrough()).optional().describe("The manifest: [{key,label,type,description,rules,example,fallback,required}]."),
|
|
183
|
+
content_source: z.object({}).passthrough().optional().describe("Optional curated-bank binding instead of generating values."),
|
|
184
|
+
render_tech: z.string().optional().describe("How the agent turns this into a file."),
|
|
185
|
+
is_active: z.boolean().optional(),
|
|
186
|
+
},
|
|
187
|
+
(a) => vh("/templates.php", { method: "POST", body: a })
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
// ── Content templates: wire one to a brand ──
|
|
191
|
+
tool(server, "viralhunt_assign_template",
|
|
192
|
+
"Assign (or unassign) a content template to a project, so that project's agents see it via viralhunt_list_templates with assigned=true. Owner/admin only.",
|
|
193
|
+
{
|
|
194
|
+
project_id: z.number().int().describe("Project id (from viralhunt_targets)."),
|
|
195
|
+
template_id: z.number().int().describe("Template id (from viralhunt_list_templates)."),
|
|
196
|
+
action: z.enum(["add", "remove"]).optional().describe("add (default) or remove."),
|
|
197
|
+
},
|
|
198
|
+
(a) => vh("/template-assignments.php", { method: "POST", body: { project_id: a.project_id, template_id: a.template_id, action: a.action || "add" } })
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// ── Cancel a scheduled post ──
|
|
202
|
+
tool(server, "viralhunt_cancel_post",
|
|
203
|
+
"Cancel the not-yet-published targets of a scheduled post. If everything already published you'll get a 409 — delete it from the platform instead.",
|
|
204
|
+
{ id: z.number().int().describe("The post id.") },
|
|
205
|
+
(a) => vh("/schedule.php", { method: "POST", query: { action: "cancel" }, body: { id: a.id } })
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// ── When to post ──
|
|
209
|
+
tool(server, "viralhunt_best_time",
|
|
210
|
+
[
|
|
211
|
+
"Best time to post on a network, measured on the posts that went viral there in the last 365 days (their OWN publish time).",
|
|
212
|
+
"Returns best_slots (weekday + hour, ranked by average engagement, each with its sample size and hit_rate_pct = share of that slot's",
|
|
213
|
+
"posts that reached the network's top 10%), worst_slot, today's best hours, by_hour and by_weekday tables, proof_posts, and",
|
|
214
|
+
"sample {posts, window, from, to}. Read `confidence` (high >= 500 posts, medium >= 200, low) before quoting an hour. Hours are",
|
|
215
|
+
"rotated to `timezone`; weekday aggregates stay UTC. With `keyword`, slots are recomputed on posts whose caption contains it;",
|
|
216
|
+
"if that sample is under 200 posts the response says fallback:true and returns the network-wide slots instead.",
|
|
217
|
+
].join(" "),
|
|
218
|
+
{
|
|
219
|
+
network: z.enum(["tiktok", "instagram", "x", "facebook", "pinterest", "bluesky", "mastodon", "douyin", "tumblr", "reddit"]).describe("Which network."),
|
|
220
|
+
timezone: z.string().optional().describe("IANA zone to express hours in, e.g. America/Mexico_City (default UTC)."),
|
|
221
|
+
keyword: z.string().optional().describe("Optional niche word; slots recomputed on posts whose caption contains it (falls back when thin)."),
|
|
222
|
+
},
|
|
223
|
+
(a) => vh("/best-time.php", { query: { network: a.network, timezone: a.timezone, keyword: a.keyword } })
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// ── Top hashtags ──
|
|
227
|
+
tool(server, "viralhunt_top_hashtags",
|
|
228
|
+
[
|
|
229
|
+
"Top hashtags from the captions of the posts we hold, per network or across all, optionally filtered to tags containing a word.",
|
|
230
|
+
"Each row carries posts (sample size), total_engagement and per_post (the number to compare: a tag used less but hitting harder).",
|
|
231
|
+
"`window` is all-time over the corpus with updated_at (there is no per-day hashtag history; say so if asked for 'this week').",
|
|
232
|
+
"Pass `hashtag` to get one tag broken down by network with its top posts.",
|
|
233
|
+
].join(" "),
|
|
234
|
+
{
|
|
235
|
+
network: z.enum(["tiktok", "instagram", "x", "facebook", "pinterest", "bluesky", "mastodon", "douyin", "tumblr"]).optional().describe("One network, or omit for all networks summed."),
|
|
236
|
+
q: z.string().optional().describe("Only hashtags containing this word (niche filter)."),
|
|
237
|
+
hashtag: z.string().optional().describe("One hashtag (with or without #) for its per-network breakdown."),
|
|
238
|
+
sort: z.enum(["engagement", "posts", "per_post"]).optional().describe("Ranking (default engagement)."),
|
|
239
|
+
min_posts: z.number().int().min(1).optional().describe("Minimum posts a tag needs to be listed (default 3)."),
|
|
240
|
+
per_page: z.number().int().min(1).max(100).optional(),
|
|
241
|
+
page: z.number().int().min(1).optional(),
|
|
242
|
+
},
|
|
243
|
+
(a) => vh("/hashtags.php", { query: { network: a.network, q: a.q, hashtag: a.hashtag, sort: a.sort, min_posts: a.min_posts, per_page: a.per_page || 20, page: a.page } })
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// ── Trending sounds ──
|
|
247
|
+
tool(server, "viralhunt_trending_sounds",
|
|
248
|
+
[
|
|
249
|
+
"Trending sounds/audio on TikTok, Instagram Reels and Douyin, ranked by the engagement of the posts that used them, cross-network",
|
|
250
|
+
"sounds first. A sound is listed only when several different accounts used it (one account's audio is a voiceover, not a trend).",
|
|
251
|
+
"Each sound carries networks{tiktok|instagram|douyin: posts, creators, eng, per_post}, cross (on more than one network) and stronger",
|
|
252
|
+
"(tiktok|instagram|even, per post) when both sides are measured. Pass `slug` for one sound with the posts that used it.",
|
|
253
|
+
"Window is all-time over the corpus.",
|
|
254
|
+
].join(" "),
|
|
255
|
+
{
|
|
256
|
+
network: z.enum(["tiktok", "instagram", "douyin", "all"]).optional().describe("One network or all (default all)."),
|
|
257
|
+
q: z.string().optional().describe("Filter by words in the title or artist."),
|
|
258
|
+
cross_only: z.boolean().optional().describe("Only sounds trending on more than one network."),
|
|
259
|
+
slug: z.string().optional().describe("One sound's slug (from a previous result) for its detail and posts."),
|
|
260
|
+
per_page: z.number().int().min(1).max(100).optional(),
|
|
261
|
+
page: z.number().int().min(1).optional(),
|
|
262
|
+
},
|
|
263
|
+
(a) => vh("/sounds.php", { query: { network: a.network, q: a.q, cross_only: a.cross_only ? 1 : undefined, slug: a.slug, per_page: a.per_page || 20, page: a.page } })
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// ── Best communities ──
|
|
267
|
+
tool(server, "viralhunt_best_communities",
|
|
268
|
+
[
|
|
269
|
+
"Where to post a topic: the best subreddits (network=reddit) or Bluesky custom feeds (network=bluesky) from our own measurements.",
|
|
270
|
+
"Reddit rows are ranked by peak_per_1k, the best score we hold per 1,000 members (upside relative to size); no average score is",
|
|
271
|
+
"published because a swept community's sample is its greatest hits. Each row carries members, sample.posts and the window of post",
|
|
272
|
+
"dates. Pass `subreddit` for one community: timing (best UTC hours/day the climbing posts were posted), pace, flairs, type mix,",
|
|
273
|
+
"top posts and similar communities. Bluesky rows carry posts, authors, avg_likes; pass `feed` (slug) for its top posts, authors, tags.",
|
|
274
|
+
].join(" "),
|
|
275
|
+
{
|
|
276
|
+
network: z.enum(["reddit", "bluesky"]).optional().describe("reddit (default) or bluesky."),
|
|
277
|
+
q: z.string().optional().describe("Topic words matched against the community name (and, on Bluesky, the feed description)."),
|
|
278
|
+
subreddit: z.string().optional().describe("reddit: one community by name for its full detail."),
|
|
279
|
+
feed: z.string().optional().describe("bluesky: one feed by slug for its full detail."),
|
|
280
|
+
sort: z.string().optional().describe("reddit: upside (default), members, peak, posts. bluesky: posts (default), avg_likes, top_likes, feed_likes."),
|
|
281
|
+
min_members: z.number().int().min(0).optional().describe("reddit: only communities with at least this many members."),
|
|
282
|
+
max_members: z.number().int().min(0).optional().describe("reddit: only communities with at most this many members (smaller rooms are easier to climb)."),
|
|
283
|
+
per_page: z.number().int().min(1).max(100).optional(),
|
|
284
|
+
page: z.number().int().min(1).optional(),
|
|
285
|
+
},
|
|
286
|
+
(a) => vh("/communities.php", { query: { network: a.network || "reddit", q: a.q, subreddit: a.subreddit, feed: a.feed, sort: a.sort, min_members: a.min_members, max_members: a.max_members, per_page: a.per_page || 20, page: a.page } })
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// ── Editorial board (team kanban): curate before publishing ──
|
|
290
|
+
tool(server, "viralhunt_board_context",
|
|
291
|
+
"The team's Editorial Board in one call: organization, members (people and agents, with ids to assign cards to), columns (with is_default / is_done flags) and categories. Call this before creating or moving cards.",
|
|
292
|
+
{},
|
|
293
|
+
() => vh("/context.php")
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
tool(server, "viralhunt_create_card",
|
|
297
|
+
[
|
|
298
|
+
"Create a card on the Editorial Board: a piece of content to curate, with an optional post URL (title/description/image are filled",
|
|
299
|
+
"from the URL's metadata when omitted), assignee, priority, due date, category and column. Returns the card with its id.",
|
|
300
|
+
"Use it to hand a trending post to a teammate instead of publishing it directly.",
|
|
301
|
+
].join(" "),
|
|
302
|
+
{
|
|
303
|
+
title: z.string().optional().describe("Card title (required unless post_url is given)."),
|
|
304
|
+
post_url: z.string().optional().describe("The post/article this card is about; metadata is fetched when title/image are missing."),
|
|
305
|
+
description: z.string().optional(),
|
|
306
|
+
priority: z.enum(["low", "medium", "high", "urgent"]).optional(),
|
|
307
|
+
due_date: z.string().optional().describe("YYYY-MM-DD."),
|
|
308
|
+
assigned_to_user_id: z.number().int().optional().describe("A member id from viralhunt_board_context."),
|
|
309
|
+
category_id: z.number().int().optional(),
|
|
310
|
+
card_type: z.string().optional().describe("Post, Note, Article, Video… (the org's card types)."),
|
|
311
|
+
board_column_id: z.number().int().optional().describe("Target column (default: the board's default column)."),
|
|
312
|
+
image_url: z.string().optional(),
|
|
313
|
+
platform: z.string().optional().describe("tiktok, instagram, x, facebook, pinterest, rss… (detected from post_url when omitted)."),
|
|
314
|
+
notes: z.string().optional(),
|
|
315
|
+
},
|
|
316
|
+
(a) => vh("/cards.php", { method: "POST", body: a })
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
tool(server, "viralhunt_move_card",
|
|
320
|
+
"Move a card to another column (moving into the column flagged is_done completes it; a 'done' comment does not).",
|
|
321
|
+
{
|
|
322
|
+
card_id: z.number().int(),
|
|
323
|
+
board_column_id: z.number().int().describe("Target column id from viralhunt_board_context."),
|
|
324
|
+
position: z.number().int().min(0).optional().describe("Position inside the column (0 = top)."),
|
|
325
|
+
},
|
|
326
|
+
(a) => vh("/cards.php", { method: "POST", body: { action: "move", card_id: a.card_id, board_column_id: a.board_column_id, position: a.position } })
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
tool(server, "viralhunt_my_cards",
|
|
330
|
+
"Cards assigned to the authenticated token's member (your own workload when you are an agent member of the team). Optional status filter.",
|
|
331
|
+
{
|
|
332
|
+
status: z.enum(["pending", "in_progress", "completed"]).optional(),
|
|
333
|
+
project: z.string().optional().describe("Restrict to one project/brand by name."),
|
|
334
|
+
per_page: z.number().int().min(1).max(100).optional(),
|
|
335
|
+
page: z.number().int().min(1).optional(),
|
|
336
|
+
},
|
|
337
|
+
(a) => vh("/my-cards.php", { query: a })
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
tool(server, "viralhunt_card_comments",
|
|
341
|
+
"Read the comments on a card, or add one (comment set). New comments notify the assignee and appear in the team chat's #board channel.",
|
|
342
|
+
{
|
|
343
|
+
card_id: z.number().int(),
|
|
344
|
+
comment: z.string().optional().describe("When given, posts this comment; when omitted, lists the card's comments."),
|
|
345
|
+
},
|
|
346
|
+
(a) => a.comment
|
|
347
|
+
? vh("/comments.php", { method: "POST", body: { card_id: a.card_id, comment: a.comment } })
|
|
348
|
+
: vh("/comments.php", { query: { card_id: a.card_id } })
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
await server.connect(new StdioServerTransport());
|
|
352
|
+
console.error("viralhunt-mcp running (base: " + BASE_URL + ")");
|
package/package.json
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "viralhunt-mcp",
|
|
3
|
-
"mcpName": "io.github.rodvan/viralhunt-mcp",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "MCP server for ViralHunt.io
|
|
6
|
-
"type": "module",
|
|
7
|
-
"bin": {
|
|
8
|
-
"viralhunt-mcp": "index.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"index.js",
|
|
12
|
-
"README.md"
|
|
13
|
-
],
|
|
14
|
-
"engines": {
|
|
15
|
-
"node": ">=18"
|
|
16
|
-
},
|
|
17
|
-
"keywords": [
|
|
18
|
-
"mcp",
|
|
19
|
-
"model-context-protocol",
|
|
20
|
-
"social-media",
|
|
21
|
-
"trending",
|
|
22
|
-
"scheduler",
|
|
23
|
-
"viralhunt",
|
|
24
|
-
"buzzsumo-alternative"
|
|
25
|
-
],
|
|
26
|
-
"homepage": "https://viralhunt.io",
|
|
27
|
-
"license": "MIT",
|
|
28
|
-
"dependencies": {
|
|
29
|
-
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
30
|
-
"zod": "^3.23.8"
|
|
31
|
-
}
|
|
32
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "viralhunt-mcp",
|
|
3
|
+
"mcpName": "io.github.rodvan/viralhunt-mcp",
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "MCP server for ViralHunt.io: find trending/viral content across TikTok, Instagram, X, Facebook, Pinterest, Bluesky, Douyin, Reddit, Mastodon, Tumblr, Hacker News and news RSS; learn the best time to post per network from measured viral posts; get top hashtags, trending sounds and the best subreddits or Bluesky feeds for a topic; and schedule/publish/edit posts on your connected social accounts.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"viralhunt-mcp": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mcp",
|
|
19
|
+
"model-context-protocol",
|
|
20
|
+
"social-media",
|
|
21
|
+
"trending",
|
|
22
|
+
"scheduler",
|
|
23
|
+
"viralhunt",
|
|
24
|
+
"buzzsumo-alternative"
|
|
25
|
+
],
|
|
26
|
+
"homepage": "https://viralhunt.io",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
30
|
+
"zod": "^3.23.8"
|
|
31
|
+
}
|
|
32
|
+
}
|