sortviral-mcp 0.0.0-stage → 1.0.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 CHANGED
@@ -1,3 +1,24 @@
1
- # Temporary Holding Version
1
+ # sortviral-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Use [SortViral](https://sortviral.com) from Claude Desktop, Claude Code, Cursor, Windsurf or any app that runs local MCP servers. Ask "find the outliers on @mrbeast's TikTok from the last 90 days" and SortViral loads and scores the page in your own Chrome, signed in as you.
4
+
5
+ This package only passes messages between your AI app and the SortViral Chrome extension over `ws://127.0.0.1` (ports 47821–47825). It has no dependencies and sends nothing to any server.
6
+
7
+ ## Setup
8
+
9
+ 1. Install SortViral in Chrome and turn on **Use with AI** (the AI button in the SortViral bar, or the toolbar menu).
10
+ 2. Add the server to your AI app:
11
+
12
+ ```json
13
+ { "mcpServers": { "sortviral": { "command": "npx", "args": ["-y", "sortviral-mcp"] } } }
14
+ ```
15
+
16
+ Claude Code: `claude mcp add sortviral -- npx -y sortviral-mcp`
17
+
18
+ 3. The first time, SortViral shows a 4-digit code. Check it matches the code your AI app reports and click **Approve**.
19
+
20
+ ## Tools
21
+
22
+ `sort_page`, `find_outliers`, `scan_watchlist`, `list_watchlist`, `add_to_watchlist`, `get_post`, `get_transcript`, `find_formats`, `search`. Read-only: nothing is posted, liked or followed. Requests run one at a time at the same pace as the extension.
23
+
24
+ Only Chrome extensions can connect to the bridge (web pages are refused by origin). The pairing token is stored in `~/.config/sortviral-mcp/pair.json`.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../index.js";
3
+ main();
package/index.js ADDED
@@ -0,0 +1,120 @@
1
+ // sortviral-mcp: a local MCP server (stdio) that forwards tool calls to the SortViral Chrome extension over
2
+ // ws://127.0.0.1. It only passes messages; the extension loads and scores posts in the user's own browser.
3
+ // Pairing: the first time the extension connects, both sides show the same 4-digit code and the user
4
+ // approves it in SortViral. The token is kept in ~/.config/sortviral-mcp/pair.json.
5
+ import { randomBytes, randomInt } from 'node:crypto';
6
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { listen } from './ws.js';
10
+ import { TOOLS, TOOL_NAMES } from './tools.js';
11
+
12
+ const VERSION = '1.0.0';
13
+ export const PORTS = [47821, 47822, 47823, 47824, 47825];
14
+ const DIR = process.env.SORTVIRAL_MCP_DIR || join(homedir(), '.config', 'sortviral-mcp');
15
+ const CALL_TIMEOUT = 10 * 60e3;
16
+ const log = (...a) => process.stderr.write(`[sortviral-mcp] ${a.join(' ')}\n`);
17
+
18
+ // ---- pairing and the extension link ----
19
+ let token = null, ext = null, paired = false, code = null, seq = 0;
20
+ const pending = new Map();
21
+ async function loadToken() { try { token = JSON.parse(await readFile(join(DIR, 'pair.json'), 'utf8')).token || null; } catch { token = null; } }
22
+ async function saveToken(t) { await mkdir(DIR, { recursive: true }); await writeFile(join(DIR, 'pair.json'), JSON.stringify({ token: t }), { mode: 0o600 }); token = t; }
23
+
24
+ function onClient(conn) {
25
+ if (ext) ext.close();
26
+ ext = conn; paired = false;
27
+ conn.on('message', async msg => {
28
+ if (msg.type === 'hello') {
29
+ if (token && msg.token === token) { paired = true; code = null; conn.send({ type: 'ready', version: VERSION }); log('extension connected'); }
30
+ else { code = String(randomInt(1000, 10000)); conn.send({ type: 'pair', code }); log(`pairing code ${code}`); }
31
+ } else if (msg.type === 'approve') {
32
+ if (!code || msg.code !== code) return conn.send({ type: 'pair', code: code || String(randomInt(1000, 10000)), wrong: true });
33
+ const t = randomBytes(24).toString('hex');
34
+ await saveToken(t);
35
+ paired = true; code = null;
36
+ conn.send({ type: 'paired', token: t, version: VERSION });
37
+ log('paired');
38
+ } else if (msg.type === 'result' && pending.has(msg.id)) {
39
+ const p = pending.get(msg.id); pending.delete(msg.id); clearTimeout(p.timer);
40
+ msg.error ? p.reject(Object.assign(new Error(msg.error.message || 'Failed'), { code: msg.error.code })) : p.resolve(msg.result);
41
+ }
42
+ });
43
+ conn.on('close', () => {
44
+ if (ext !== conn) return;
45
+ ext = null; paired = false;
46
+ for (const [id, p] of pending) { clearTimeout(p.timer); p.reject(new Error('SortViral disconnected (Chrome closed or the AI connection was turned off).')); pending.delete(id); }
47
+ });
48
+ }
49
+
50
+ export function notReady() {
51
+ if (!ext) return 'SortViral is not connected. In Chrome, open SortViral (the toolbar icon or the AI button in the SortViral bar) and turn on "Use with AI". Chrome must stay open.';
52
+ if (!paired) return `Approve this connection in Chrome: SortViral shows a pairing code; check it is ${code} and click Approve. Then ask again.`;
53
+ return null;
54
+ }
55
+
56
+ function callExtension(tool, args) {
57
+ return new Promise((resolve, reject) => {
58
+ const id = ++seq;
59
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error('SortViral took longer than 10 minutes. Try a smaller page or a limit.')); }, CALL_TIMEOUT);
60
+ pending.set(id, { resolve, reject, timer });
61
+ ext.send({ type: 'call', id, tool, args });
62
+ });
63
+ }
64
+
65
+ // ---- MCP over stdio (JSON-RPC 2.0, one message per line) ----
66
+ const out = obj => process.stdout.write(JSON.stringify(obj) + '\n');
67
+ const reply = (id, result) => out({ jsonrpc: '2.0', id, result });
68
+ const fail = (id, code, message) => out({ jsonrpc: '2.0', id, error: { code, message } });
69
+
70
+ export async function handle(msg) {
71
+ const { id, method, params } = msg;
72
+ if (method === 'initialize') {
73
+ return reply(id, {
74
+ protocolVersion: params?.protocolVersion || '2025-06-18',
75
+ capabilities: { tools: { listChanged: false } },
76
+ serverInfo: { name: 'sortviral', title: 'SortViral', version: VERSION },
77
+ instructions: 'SortViral sorts Instagram, TikTok, YouTube, Facebook and Pinterest pages by views, outlier score, rising speed and engagement, in the user\'s own Chrome. Read-only: it never posts, likes or follows.'
78
+ });
79
+ }
80
+ if (method === 'ping') return reply(id, {});
81
+ if (method === 'tools/list') return reply(id, { tools: TOOLS });
82
+ if (method === 'tools/call') {
83
+ const name = params?.name;
84
+ if (!TOOL_NAMES.has(name)) return fail(id, -32602, `Unknown tool: ${name}`);
85
+ const wait = notReady();
86
+ if (wait) return reply(id, { content: [{ type: 'text', text: wait }], isError: true });
87
+ try {
88
+ const result = await callExtension(name, params.arguments || {});
89
+ return reply(id, { content: [{ type: 'text', text: typeof result === 'string' ? result : JSON.stringify(result, null, 2) }], ...(result && typeof result === 'object' ? { structuredContent: Array.isArray(result) ? { items: result } : result } : {}) });
90
+ } catch (e) {
91
+ return reply(id, { content: [{ type: 'text', text: e.message }], isError: true });
92
+ }
93
+ }
94
+ if (id !== undefined && id !== null) return fail(id, -32601, `Method not found: ${method}`);
95
+ }
96
+
97
+ export async function main() {
98
+ await loadToken();
99
+ let server = null;
100
+ for (const port of PORTS) {
101
+ try { server = await listen(port, onClient); log(`listening on 127.0.0.1:${port}`); break; } catch (e) { if (e.code !== 'EADDRINUSE') throw e; }
102
+ }
103
+ if (!server) log(`ports ${PORTS[0]}-${PORTS.at(-1)} are busy; close other SortViral MCP servers`);
104
+ let buf = '';
105
+ process.stdin.setEncoding('utf8');
106
+ process.stdin.on('data', chunk => {
107
+ buf += chunk;
108
+ let i;
109
+ while ((i = buf.indexOf('\n')) >= 0) {
110
+ const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
111
+ if (!line) continue;
112
+ let msg; try { msg = JSON.parse(line); } catch { fail(null, -32700, 'Parse error'); continue; }
113
+ handle(msg).catch(e => msg.id != null && fail(msg.id, -32603, e.message));
114
+ }
115
+ });
116
+ process.stdin.on('end', () => process.exit(0));
117
+ // A ping keeps the extension's service worker awake while an AI app is open.
118
+ setInterval(() => ext?.send({ type: 'ping' }), 20e3).unref();
119
+ }
120
+
package/package.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "sortviral-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Use SortViral with Claude, Cursor or any MCP app: sort Instagram, TikTok, YouTube, Facebook and Pinterest pages by views and outlier score in your own Chrome.",
5
+ "type": "module",
6
+ "bin": { "sortviral-mcp": "bin/sortviral-mcp.js" },
7
+ "files": ["bin", "index.js", "tools.js", "ws.js", "README.md"],
8
+ "engines": { "node": ">=18" },
9
+ "license": "MIT",
10
+ "homepage": "https://sortviral.com/ai/",
11
+ "keywords": ["mcp", "model-context-protocol", "instagram", "tiktok", "youtube", "outlier", "sortviral"],
12
+ "scripts": { "test": "node --test test/*.test.mjs" }
13
+ }
package/tools.js ADDED
@@ -0,0 +1,52 @@
1
+ // The tools SortViral offers an AI app. The extension does the work in the user's own browser; this list
2
+ // only describes them. Same numbers as the in-page bar, capped at 50 posts unless `limit` says otherwise.
3
+ const METRICS = ['views', 'outlier', 'rising', 'engagement', 'likes', 'comments', 'shares', 'saves', 'newest'];
4
+ const limit = { type: 'integer', minimum: 1, maximum: 500, default: 50, description: 'How many posts to return (the whole page is still loaded and scored).' };
5
+ const metric = { type: 'string', enum: METRICS, default: 'outlier', description: 'Sort order. Outlier = views ÷ the creator\'s usual (median of their 30 most recent posts); on search, hashtag and sound pages it is views ÷ the creator\'s followers. Rising = views per day for posts under 30 days old. Metrics a platform does not publish are skipped.' };
6
+ const days = { type: 'integer', minimum: 1, maximum: 3650, description: 'Only posts from the last N days.' };
7
+
8
+ export const TOOLS = [
9
+ {
10
+ name: 'sort_page',
11
+ description: 'Load every post on a creator page (Instagram Reels or Posts tab, TikTok profile, YouTube Videos or Shorts tab, Facebook Reels tab, Pinterest profile or board) or a search, hashtag or sound page, then sort it. Runs in a background tab of the user\'s Chrome, signed in as them.',
12
+ inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'The page link, e.g. https://www.tiktok.com/@mrbeast or https://www.instagram.com/mkbhd/reels/' }, metric, limit, days }, required: ['url'] }
13
+ },
14
+ {
15
+ name: 'find_outliers',
16
+ description: 'Scan several creators and return their best posts combined, each scored against its own creator\'s usual. Creators are profile links on any supported platform.',
17
+ inputSchema: { type: 'object', properties: { creators: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 25, description: 'Profile links' }, days: { ...days, default: 90 }, metric, limit: { ...limit, default: 20 } }, required: ['creators'] }
18
+ },
19
+ {
20
+ name: 'scan_watchlist',
21
+ description: 'Scan every creator on the user\'s SortViral Watchlist and return the combined top posts, each scored against its own creator.',
22
+ inputSchema: { type: 'object', properties: { days: { ...days, default: 90 }, metric, limit: { ...limit, default: 20 } } }
23
+ },
24
+ { name: 'list_watchlist', description: 'List the creators on the user\'s SortViral Watchlist, and their bookmarked posts.', inputSchema: { type: 'object', properties: {} } },
25
+ {
26
+ name: 'add_to_watchlist',
27
+ description: 'Add a creator to the SortViral Watchlist by profile link.',
28
+ inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'Profile link, e.g. https://www.tiktok.com/@khaby.lame' } }, required: ['url'] }
29
+ },
30
+ {
31
+ name: 'get_post',
32
+ description: 'Every number SortViral has for one post (views, likes, comments, shares, saves where published, outlier score against the creator, date, caption), loading the creator\'s page if needed.',
33
+ inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'Post link' } }, required: ['url'] }
34
+ },
35
+ {
36
+ name: 'get_transcript',
37
+ description: 'The published captions of a TikTok or YouTube video as plain text. Instagram, Facebook and Pinterest do not publish captions on the web.',
38
+ inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'Video link' } }, required: ['url'] }
39
+ },
40
+ {
41
+ name: 'find_formats',
42
+ description: 'Return a creator\'s loaded posts (or every scanned Watchlist creator\'s) with captions, transcripts where published and outlier scores, grouped for you to name the recurring formats: hook pattern plus how it is filmed. Rank formats by the median outlier score of their posts.',
43
+ inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'Creator page link, or "watchlist"' }, limit: { ...limit, default: 100 } }, required: ['url'] }
44
+ },
45
+ {
46
+ name: 'search',
47
+ description: 'Search a platform and sort the results. Outlier on search pages = views (or likes/saves) ÷ the creator\'s followers.',
48
+ inputSchema: { type: 'object', properties: { platform: { type: 'string', enum: ['instagram', 'tiktok', 'pinterest'] }, query: { type: 'string' }, metric, limit }, required: ['platform', 'query'] }
49
+ }
50
+ ];
51
+
52
+ export const TOOL_NAMES = new Set(TOOLS.map(t => t.name));
package/ws.js ADDED
@@ -0,0 +1,86 @@
1
+ // A minimal WebSocket server (RFC 6455) for one local client: the SortViral extension's service worker.
2
+ // Text frames only, no extensions. Kept dependency-free so `npx sortviral-mcp` installs nothing else.
3
+ import { createServer } from 'node:http';
4
+ import { createHash } from 'node:crypto';
5
+
6
+ const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
7
+
8
+ // Only Chrome extensions may connect: a web page's Origin is http(s)://…, so a site can't reach the bridge.
9
+ export const allowedOrigin = origin => /^chrome-extension:\/\/[a-p]{32}$/.test(String(origin || ''));
10
+
11
+ export function encodeFrame(text, opcode = 0x1) {
12
+ const data = Buffer.from(text);
13
+ const len = data.length;
14
+ const head = len < 126 ? Buffer.from([0x80 | opcode, len])
15
+ : len < 65536 ? Buffer.from([0x80 | opcode, 126, len >> 8, len & 255])
16
+ : Buffer.concat([Buffer.from([0x80 | opcode, 127]), (() => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(len)); return b; })()]);
17
+ return Buffer.concat([head, data]);
18
+ }
19
+
20
+ // Parses as many complete frames as `buf` holds. Returns { frames: [{ opcode, fin, payload }], rest }.
21
+ export function decodeFrames(buf) {
22
+ const frames = [];
23
+ let off = 0;
24
+ while (buf.length - off >= 2) {
25
+ const b0 = buf[off], b1 = buf[off + 1];
26
+ let len = b1 & 127, pos = off + 2;
27
+ if (len === 126) { if (buf.length - pos < 2) break; len = buf.readUInt16BE(pos); pos += 2; }
28
+ else if (len === 127) { if (buf.length - pos < 8) break; len = Number(buf.readBigUInt64BE(pos)); pos += 8; }
29
+ const masked = !!(b1 & 128);
30
+ if (masked) { if (buf.length - pos < 4) break; }
31
+ const mask = masked ? buf.subarray(pos, pos + 4) : null;
32
+ if (masked) pos += 4;
33
+ if (buf.length - pos < len) break;
34
+ const payload = Buffer.from(buf.subarray(pos, pos + len));
35
+ if (mask) for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i & 3];
36
+ frames.push({ fin: !!(b0 & 128), opcode: b0 & 15, payload });
37
+ off = pos + len;
38
+ }
39
+ return { frames, rest: buf.subarray(off) };
40
+ }
41
+
42
+ // Starts listening on 127.0.0.1:port. onClient(conn) gets { send(obj), close(), on('message'|'close', fn) }.
43
+ export function listen(port, onClient) {
44
+ return new Promise((resolve, reject) => {
45
+ const server = createServer((req, res) => { res.writeHead(426, { 'content-type': 'text/plain' }); res.end('SortViral MCP bridge. Connect from the SortViral extension.\n'); });
46
+ server.on('upgrade', (req, socket) => {
47
+ const key = req.headers['sec-websocket-key'];
48
+ if (!key || !allowedOrigin(req.headers.origin) || req.headers.upgrade?.toLowerCase() !== 'websocket') {
49
+ socket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
50
+ return;
51
+ }
52
+ const accept = createHash('sha1').update(key + GUID).digest('base64');
53
+ socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
54
+ socket.setNoDelay(true);
55
+ const handlers = { message: [], close: [] };
56
+ let buf = Buffer.alloc(0), parts = [], closed = false;
57
+ const conn = {
58
+ origin: req.headers.origin,
59
+ send(obj) { if (!closed) socket.write(encodeFrame(JSON.stringify(obj))); },
60
+ close() { if (closed) return; closed = true; try { socket.write(encodeFrame('', 0x8)); } catch { /* gone */ } socket.end(); },
61
+ on(ev, fn) { handlers[ev].push(fn); }
62
+ };
63
+ const gone = () => { if (closed === 'done') return; closed = 'done'; handlers.close.forEach(fn => fn()); };
64
+ socket.on('data', chunk => {
65
+ buf = Buffer.concat([buf, chunk]);
66
+ const { frames, rest } = decodeFrames(buf);
67
+ buf = Buffer.from(rest);
68
+ for (const f of frames) {
69
+ if (f.opcode === 0x8) { conn.close(); return gone(); }
70
+ if (f.opcode === 0x9) { socket.write(encodeFrame(f.payload.toString(), 0xA)); continue; }
71
+ if (f.opcode === 0xA) continue;
72
+ parts.push(f.payload);
73
+ if (!f.fin) continue;
74
+ const text = Buffer.concat(parts).toString('utf8'); parts = [];
75
+ let msg; try { msg = JSON.parse(text); } catch { continue; }
76
+ handlers.message.forEach(fn => fn(msg));
77
+ }
78
+ });
79
+ socket.on('close', gone);
80
+ socket.on('error', gone);
81
+ onClient(conn);
82
+ });
83
+ server.once('error', reject);
84
+ server.listen(port, '127.0.0.1', () => { server.off('error', reject); resolve(server); });
85
+ });
86
+ }