@orchyn/mcp 1.3.4 → 1.4.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 +7 -4
- package/dist/index.js +8 -184
- package/dist/oauth.js +11 -60
- package/dist/orchyn.js +4 -162
- package/dist/shared/oauth.js +86 -0
- package/dist/shared/orchyn.js +235 -0
- package/dist/shared/tools-def.js +75 -0
- package/dist/shared/tools.js +300 -0
- package/dist/shared/video.js +152 -0
- package/dist/video.js +4 -139
- package/package.json +10 -2
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-agnostic orchyn API client — the single source of truth for the
|
|
3
|
+
* Node package (`@orchyn/mcp`) and the Cloudflare Worker (mcp.orchyn.com).
|
|
4
|
+
*
|
|
5
|
+
* Only Web-standard APIs are used (fetch, crypto, atob), so both runtimes
|
|
6
|
+
* import this same module: no duplicated client logic to keep in sync. Token
|
|
7
|
+
* storage lives behind the `TokenProvider` interface — a credentials file for
|
|
8
|
+
* the Node CLI, a KV-backed session for the Worker.
|
|
9
|
+
*/
|
|
10
|
+
export class OrchynError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
code;
|
|
13
|
+
paywall;
|
|
14
|
+
body;
|
|
15
|
+
constructor(status, message, opts = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "OrchynError";
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.code = opts.code;
|
|
20
|
+
this.paywall = opts.paywall;
|
|
21
|
+
this.body = opts.body;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Extracts the `exp` claim (epoch seconds) from an orchyn JWT without
|
|
26
|
+
* verifying the signature — enough to decide whether to refresh before the
|
|
27
|
+
* 15-minute access token expires.
|
|
28
|
+
*/
|
|
29
|
+
export function jwtExpiry(token) {
|
|
30
|
+
try {
|
|
31
|
+
const payload = token.split(".")[1];
|
|
32
|
+
if (!payload)
|
|
33
|
+
return undefined;
|
|
34
|
+
const pad = payload.length % 4 === 0 ? "" : "=".repeat(4 - (payload.length % 4));
|
|
35
|
+
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
|
36
|
+
const claims = JSON.parse(json);
|
|
37
|
+
return typeof claims.exp === "number" ? claims.exp : undefined;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class OrchynClient {
|
|
44
|
+
baseUrl;
|
|
45
|
+
tokenProvider;
|
|
46
|
+
constructor(baseUrl, tokenProvider) {
|
|
47
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
48
|
+
this.tokenProvider = tokenProvider;
|
|
49
|
+
}
|
|
50
|
+
async request(method, path, opts = {}) {
|
|
51
|
+
const doRequest = async (accessToken) => {
|
|
52
|
+
const headers = {};
|
|
53
|
+
if (opts.body !== undefined) {
|
|
54
|
+
headers["content-type"] = "application/json";
|
|
55
|
+
}
|
|
56
|
+
if (accessToken) {
|
|
57
|
+
headers.authorization = `Bearer ${accessToken}`;
|
|
58
|
+
}
|
|
59
|
+
return fetch(`${this.baseUrl}${path}`, {
|
|
60
|
+
method,
|
|
61
|
+
headers,
|
|
62
|
+
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
let token = opts.token;
|
|
66
|
+
if (opts.auth && !token) {
|
|
67
|
+
token = await this.tokenProvider.getAccessToken();
|
|
68
|
+
}
|
|
69
|
+
if (opts.auth && !token) {
|
|
70
|
+
throw new OrchynError(401, "No orchyn access token available.");
|
|
71
|
+
}
|
|
72
|
+
let res = await doRequest(token);
|
|
73
|
+
if (res.status === 401 && opts.auth && this.tokenProvider.onUnauthorized) {
|
|
74
|
+
const refreshed = await this.tokenProvider.onUnauthorized();
|
|
75
|
+
if (refreshed) {
|
|
76
|
+
token = await this.tokenProvider.getAccessToken();
|
|
77
|
+
res = await doRequest(token);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return this.normalizeResponse(res, path);
|
|
81
|
+
}
|
|
82
|
+
async normalizeResponse(res, path) {
|
|
83
|
+
const text = await res.text();
|
|
84
|
+
let body;
|
|
85
|
+
try {
|
|
86
|
+
body = text ? JSON.parse(text) : undefined;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
body = undefined;
|
|
90
|
+
}
|
|
91
|
+
const json = (body ?? {});
|
|
92
|
+
const errorMessage = typeof json.error === "string" ? json.error : `orchyn API error (${res.status})`;
|
|
93
|
+
if (res.status >= 200 && res.status < 300) {
|
|
94
|
+
return body;
|
|
95
|
+
}
|
|
96
|
+
if (res.status === 402) {
|
|
97
|
+
throw new OrchynError(402, errorMessage, {
|
|
98
|
+
paywall: {
|
|
99
|
+
reason: typeof json.reason === "string" ? json.reason : undefined,
|
|
100
|
+
used: typeof json.used === "number" ? json.used : undefined,
|
|
101
|
+
max: typeof json.max === "number" ? json.max : undefined,
|
|
102
|
+
cost: typeof json.cost === "number" ? json.cost : undefined,
|
|
103
|
+
},
|
|
104
|
+
body,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
throw new OrchynError(res.status, errorMessage, {
|
|
108
|
+
code: typeof json.code === "string" ? json.code : undefined,
|
|
109
|
+
body,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async startVideoAnalysis(url, appId) {
|
|
113
|
+
const res = await this.request("POST", "/mcp/analyze-post", {
|
|
114
|
+
auth: true,
|
|
115
|
+
body: appId !== undefined ? { url, appId } : { url },
|
|
116
|
+
});
|
|
117
|
+
const inline = Array.isArray(res?._inlineImages)
|
|
118
|
+
? res._inlineImages
|
|
119
|
+
: undefined;
|
|
120
|
+
const job = res;
|
|
121
|
+
return { ...job, inlineImages: inline };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Proxies a generic orchyn backend MCP tool (`get_social_media`,
|
|
125
|
+
* `discover_social_videos`, `understand_social_post`, …) through
|
|
126
|
+
* `POST /mcp` JSON-RPC. The backend enforces per-user credit billing;
|
|
127
|
+
* tool-level failures surface as OrchynError with the backend message.
|
|
128
|
+
*/
|
|
129
|
+
async callTool(name, args) {
|
|
130
|
+
const rpc = await this.request("POST", "/mcp", {
|
|
131
|
+
auth: true,
|
|
132
|
+
body: {
|
|
133
|
+
jsonrpc: "2.0",
|
|
134
|
+
id: Date.now(),
|
|
135
|
+
method: "tools/call",
|
|
136
|
+
params: { name, arguments: args },
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
if (rpc && typeof rpc === "object" && rpc.error !== undefined && rpc.error !== null) {
|
|
140
|
+
const err = rpc.error;
|
|
141
|
+
throw new OrchynError(err.code === -32002 ? 402 : 400, typeof err.message === "string" ? err.message : "orchyn MCP tool call failed");
|
|
142
|
+
}
|
|
143
|
+
const result = (rpc.result ?? {});
|
|
144
|
+
if (result.isError) {
|
|
145
|
+
const text = result.content
|
|
146
|
+
?.filter((c) => c.type === "text")
|
|
147
|
+
.map((c) => String(c.text ?? ""))
|
|
148
|
+
.join("\n");
|
|
149
|
+
throw new OrchynError(400, text || "orchyn MCP tool call failed");
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
contentBlocks: result.content ?? [],
|
|
153
|
+
structured: result.structuredContent,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
async getJob(jobId) {
|
|
157
|
+
return this.request("GET", `/ai/analyze-post?jobId=${encodeURIComponent(jobId)}`, {
|
|
158
|
+
auth: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async me() {
|
|
162
|
+
return this.request("GET", "/auth/me", { auth: true });
|
|
163
|
+
}
|
|
164
|
+
async exchangeCompletionCode(code, workspaceId) {
|
|
165
|
+
return OrchynClient.exchangeCode(this.baseUrl, code, workspaceId);
|
|
166
|
+
}
|
|
167
|
+
/** Starts a Google sign-in for the given redirect URL; returns the Google redirectUrl. */
|
|
168
|
+
async startGoogleSignIn(redirect) {
|
|
169
|
+
return this.request("POST", "/auth/google/start", {
|
|
170
|
+
auth: false,
|
|
171
|
+
body: { redirect },
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async login(email, password) {
|
|
175
|
+
return OrchynClient.login(this.baseUrl, email, password);
|
|
176
|
+
}
|
|
177
|
+
async refresh(refreshToken) {
|
|
178
|
+
return OrchynClient.refreshSession(this.baseUrl, refreshToken);
|
|
179
|
+
}
|
|
180
|
+
static async login(baseUrl, email, password) {
|
|
181
|
+
const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/auth/login`, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "content-type": "application/json" },
|
|
184
|
+
body: JSON.stringify({ email, password }),
|
|
185
|
+
});
|
|
186
|
+
const body = await parseJsonBody(res);
|
|
187
|
+
if (res.status >= 200 && res.status < 300) {
|
|
188
|
+
return body;
|
|
189
|
+
}
|
|
190
|
+
throw new OrchynError(res.status, typeof body?.error === "string"
|
|
191
|
+
? body.error
|
|
192
|
+
: `Login failed (${res.status})`, { body });
|
|
193
|
+
}
|
|
194
|
+
/** Exchanges an orchyn one-time completion code (MCP login flow) for a session. */
|
|
195
|
+
static async exchangeCode(baseUrl, code, workspaceId) {
|
|
196
|
+
const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/auth/oauth/complete`, {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: { "content-type": "application/json" },
|
|
199
|
+
body: JSON.stringify(workspaceId !== undefined ? { code, workspaceId } : { code }),
|
|
200
|
+
});
|
|
201
|
+
const body = await parseJsonBody(res);
|
|
202
|
+
if (res.status >= 200 && res.status < 300) {
|
|
203
|
+
return body;
|
|
204
|
+
}
|
|
205
|
+
throw new OrchynError(res.status, typeof body?.error === "string"
|
|
206
|
+
? body.error
|
|
207
|
+
: `Code exchange failed (${res.status})`, { body });
|
|
208
|
+
}
|
|
209
|
+
/** Redeems a refresh token for a fresh orchyn session (rotating the refresh token). */
|
|
210
|
+
static async refreshSession(baseUrl, refreshToken) {
|
|
211
|
+
const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/auth/refresh`, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: { "content-type": "application/json" },
|
|
214
|
+
body: JSON.stringify({ refreshToken }),
|
|
215
|
+
});
|
|
216
|
+
const body = await parseJsonBody(res);
|
|
217
|
+
const json = (body ?? {});
|
|
218
|
+
if (res.status >= 200 && res.status < 300) {
|
|
219
|
+
if (!json || typeof json.accessToken !== "string") {
|
|
220
|
+
throw new OrchynError(500, "Refresh succeeded but returned no access token.");
|
|
221
|
+
}
|
|
222
|
+
return body;
|
|
223
|
+
}
|
|
224
|
+
throw new OrchynError(res.status, typeof json.error === "string" ? json.error : `Refresh failed (${res.status})`, { body });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function parseJsonBody(res) {
|
|
228
|
+
const text = await res.text();
|
|
229
|
+
try {
|
|
230
|
+
return text ? JSON.parse(text) : undefined;
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const TOOL_DEFINITIONS = [
|
|
3
|
+
{
|
|
4
|
+
name: "analyze_post",
|
|
5
|
+
title: "Analyze Post",
|
|
6
|
+
description: "Analyze a social post (video, image, carousel/slideshow) from its link — imports the media and runs AI analysis over the actual content (video frames, carousel images, caption). Supports TikTok, Instagram, YouTube, X/Twitter, Douyin, Xiaohongshu and Bilibili. Returns the full analysis once finished.",
|
|
7
|
+
inputSchema: z.object({ url: z.string().describe("Public post URL (TikTok/Instagram/YouTube/X, Douyin, Xiaohongshu or Bilibili).") }).strict(),
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
name: "get_social_media",
|
|
11
|
+
title: "Get Social Media",
|
|
12
|
+
description: "Fetch a social post's media from a TikTok, Instagram, YouTube, X/Twitter, Douyin, Xiaohongshu or Bilibili URL: contentType (video/image/carousel/slideshow), title, caption, author, stats and direct media URLs. Returns an inline thumbnail image. Consumes 1 orchyn credit.",
|
|
13
|
+
inputSchema: z.object({ url: z.string().describe("Full public post URL.") }).strict(),
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: "discover_social_posts",
|
|
17
|
+
title: "Discover Social Posts",
|
|
18
|
+
description: "Discover recent posts (video, image, carousel, slideshow) for a niche on YouTube, TikTok, Instagram, Douyin, Xiaohongshu, X/Twitter or Bilibili via TikHub. Each post includes title/caption, thumbnailUrl, externalUrl, views/likes/comments and inline thumbnails (up to 4) so they show in chat. Say \"next\" to paginate (offset), or \"analyze the 2nd one\" / \"analyze all\" for batch analysis. Consumes 2 orchyn credits.",
|
|
19
|
+
inputSchema: z.object({ niche: z.string().describe("Niche/topic, e.g. 'fitness'."), keywords: z.string().optional().describe("Optional extra keywords."), limit: z.number().int().optional().describe("Max results (default 6)."), offset: z.number().int().optional().describe("Skip first N results — for 'next' pagination."), platform: z.enum(["youtube", "tiktok", "instagram", "douyin", "xiaohongshu", "twitter", "bilibili", "any"]).optional().describe("Platform to search (default youtube).") }).strict(),
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "get_user_posts",
|
|
23
|
+
title: "Get User Posts",
|
|
24
|
+
description: "List recent posts by a creator handle (e.g. @zoundsapp) via TikHub on TikTok, Instagram, YouTube, Douyin, Xiaohongshu, X/Twitter or Bilibili. Each post includes title/caption, thumbnailUrl, externalUrl, views/likes/comments and inline thumbnails (up to 4) so they show in chat. Use this when Claude needs to pull more posts from the same account to spot a pattern, or to scan a whole profile. Consumes 2 orchyn credits.",
|
|
25
|
+
inputSchema: z.object({ username: z.string().describe("Creator handle, e.g. 'zoundsapp' or '@zoundsapp'."), platform: z.enum(["tiktok", "instagram", "youtube", "douyin", "xiaohongshu", "twitter", "bilibili"]).optional().describe("Which platform (default tiktok)."), limit: z.number().int().optional().describe("Max posts (default 6).") }).strict(),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "analyze_creator_profile",
|
|
29
|
+
title: "Analyze Creator Profile",
|
|
30
|
+
description: "Deep-dive a whole creator profile via TikHub on TikTok, Instagram, YouTube, Douyin, Xiaohongshu, X/Twitter or Bilibili: fetch recent posts, run multimodal Gemini on up to 3, then synthesize a profile report — creator summary, niche, content themes, hook styles, strengths/weaknesses, engagement patterns, audience insights, variation ideas, collaboration fit. Consumes 15 orchyn credits.",
|
|
31
|
+
inputSchema: z.object({ username: z.string().describe("Creator handle, e.g. 'zoundsapp'."), platform: z.enum(["tiktok", "instagram", "youtube", "douyin", "xiaohongshu", "twitter", "bilibili"]).optional().describe("Which platform (default tiktok)."), limit: z.number().int().optional().describe("Posts to fetch (default 6; first 3 analyzed)."), focus: z.string().optional().describe("Extra instruction for the profile synthesis.") }).strict(),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "get_post_comments",
|
|
35
|
+
title: "Get Post Comments",
|
|
36
|
+
description: "Fetch top comments for a post URL via TikHub on TikTok, Instagram, YouTube, Douyin, X/Twitter or Bilibili (plus keyword clusters from TikTok Analytics when available) — audience sentiment/audience-signal analysis. Consumes 2 orchyn credits.",
|
|
37
|
+
inputSchema: z.object({ url: z.string().describe("Full public post URL (TikTok/Instagram/YouTube/Douyin/X/Bilibili)."), limit: z.number().int().optional().describe("Max comments (default 20).") }).strict(),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "search_creators",
|
|
41
|
+
title: "Search Creators",
|
|
42
|
+
description: "Search creators by niche/keyword via TikHub on TikTok, Instagram, Xiaohongshu, YouTube or Douyin — username, nickname, follower count, signature, verified status. Use to find influencers to vet or analyze. Consumes 2 orchyn credits.",
|
|
43
|
+
inputSchema: z.object({ keyword: z.string().describe("Niche/keyword, e.g. 'fitness' or a creator name."), platform: z.enum(["tiktok", "instagram", "xiaohongshu", "youtube", "douyin"]).optional().describe("Which platform (default tiktok)."), count: z.number().int().optional().describe("Max creators (default 8).") }).strict(),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "get_similar_creators",
|
|
47
|
+
title: "Get Similar Creators",
|
|
48
|
+
description: "Find lookalike creators for a given handle via TikHub — TikTok similar-user recommendations or Instagram similar users. Useful for scaling: 'if this creator works, here are more like them'. Consumes 2 orchyn credits.",
|
|
49
|
+
inputSchema: z.object({ username: z.string().describe("Seed creator handle, e.g. 'zoundsapp'."), platform: z.enum(["tiktok", "instagram"]).optional().describe("Which platform (default tiktok).") }).strict(),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "discover_sounds",
|
|
53
|
+
title: "Discover Sounds",
|
|
54
|
+
description: "Discover trending sounds/music for a keyword on TikTok or Instagram via TikHub — the sound is a huge ranking signal for TikTok virality. Returns title, artist, duration, play/cover URLs. Consumes 2 orchyn credits.",
|
|
55
|
+
inputSchema: z.object({ keyword: z.string().describe("Niche/keyword, e.g. 'gym'."), platform: z.enum(["tiktok", "instagram"]).optional().describe("Which platform (default tiktok)."), count: z.number().int().optional().describe("Max sounds (default 6).") }).strict(),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "understand_social_post",
|
|
59
|
+
title: "Understand Social Post",
|
|
60
|
+
description: "Import a social post URL AND understand it with multimodal AI over the actual video/images: summary, hook strength, viral triggers, format breakdown and variation ideas. Includes the thumbnail. Supports TikTok, Instagram, YouTube, X/Twitter, Douyin, Xiaohongshu and Bilibili. Consumes 10 orchyn credits.",
|
|
61
|
+
inputSchema: z.object({ url: z.string().describe("Full public post URL (TikTok/Instagram/YouTube/X/Douyin/Xiaohongshu/Bilibili)."), focus: z.string().optional().describe("Extra instruction, e.g. 'focus on the CTA'.") }).strict(),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "check_orchyn_credits",
|
|
65
|
+
title: "Check Orchyn Credits",
|
|
66
|
+
description: "Check your MCP credit balance, billing URL and pack size. No cost — call anytime to see remaining credits before running other tools.",
|
|
67
|
+
inputSchema: z.object({}).strict(),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "buy_orchyn_credits",
|
|
71
|
+
title: "Buy Orchyn Credits",
|
|
72
|
+
description: "Buy an MCP credit pack via Stripe Checkout. Returns a secure checkout URL — open it in your browser to pay. Credits are added automatically after payment. No cost to call.",
|
|
73
|
+
inputSchema: z.object({}).strict(),
|
|
74
|
+
},
|
|
75
|
+
];
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The orchyn MCP tool surface, registered once and shared by the Node
|
|
3
|
+
* package (stdio/HTTP) and the Cloudflare Worker. Both runtimes supply a
|
|
4
|
+
* `makeClient` factory that resolves the caller's orchyn identity (credential
|
|
5
|
+
* file for the CLI, KV session for the worker) — the tool bodies, schemas and
|
|
6
|
+
* result formatting live here and nowhere else.
|
|
7
|
+
*/
|
|
8
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { OrchynError } from "./orchyn.js";
|
|
11
|
+
import { formatPaywallError, runVideoAnalysis, validatePostUrl } from "./video.js";
|
|
12
|
+
function toToolResult(proxy) {
|
|
13
|
+
const images = proxy.contentBlocks
|
|
14
|
+
.filter((c) => c.type === "image")
|
|
15
|
+
.map((c) => ({
|
|
16
|
+
type: "image",
|
|
17
|
+
data: String(c.data ?? ""),
|
|
18
|
+
mimeType: String(c.mimeType ?? "image/jpeg"),
|
|
19
|
+
}));
|
|
20
|
+
const text = JSON.stringify(proxy.structured ?? {}, null, 2);
|
|
21
|
+
return { content: [...images, { type: "text", text }] };
|
|
22
|
+
}
|
|
23
|
+
function toolError(prefix, err) {
|
|
24
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
25
|
+
return { content: [{ type: "text", text: `${prefix}: ${msg}` }], isError: true };
|
|
26
|
+
}
|
|
27
|
+
export function createMcpServer(makeClient) {
|
|
28
|
+
const server = new McpServer({
|
|
29
|
+
name: "orchyn-mcp",
|
|
30
|
+
version: "1.3.4",
|
|
31
|
+
});
|
|
32
|
+
server.registerTool("analyze_post", {
|
|
33
|
+
title: "Analyze Post",
|
|
34
|
+
description: "Analyze a social post (video, image, carousel/slideshow) from its link — " +
|
|
35
|
+
"imports the media and runs AI analysis over the actual content (video frames, carousel images, caption). " +
|
|
36
|
+
"Supports TikTok, Instagram, YouTube, X/Twitter, Douyin, Xiaohongshu and Bilibili. Returns the full analysis once finished.",
|
|
37
|
+
inputSchema: z
|
|
38
|
+
.object({
|
|
39
|
+
url: z.string().describe("Public post URL (TikTok/Instagram/YouTube/X, Douyin, Xiaohongshu or Bilibili)."),
|
|
40
|
+
})
|
|
41
|
+
.strict(),
|
|
42
|
+
}, async (args, extra) => {
|
|
43
|
+
const client = await makeClient(extra);
|
|
44
|
+
const validation = validatePostUrl(args.url);
|
|
45
|
+
if (!validation.ok) {
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: "text", text: `Invalid url: ${validation.error}` }],
|
|
48
|
+
isError: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const result = await runVideoAnalysis(client, validation.url);
|
|
53
|
+
const images = (result.inlineImages ?? []).map((img) => ({
|
|
54
|
+
type: "image",
|
|
55
|
+
data: String(img.data),
|
|
56
|
+
mimeType: String(img.mimeType ?? "image/jpeg"),
|
|
57
|
+
}));
|
|
58
|
+
// Remove internal thumbnail field from the JSON shown to the model
|
|
59
|
+
const { inlineImages: _omit, ...rest } = result;
|
|
60
|
+
return {
|
|
61
|
+
content: [...images, { type: "text", text: JSON.stringify(rest, null, 2) }],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (err instanceof OrchynError && err.paywall) {
|
|
66
|
+
return {
|
|
67
|
+
content: [{ type: "text", text: `Analysis blocked: ${formatPaywallError(err)}\n\nHTTP ${err.status}: ${err.message}` }],
|
|
68
|
+
isError: true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
72
|
+
return { content: [{ type: "text", text: `Analysis failed: ${msg}` }], isError: true };
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
server.registerTool("get_social_media", {
|
|
76
|
+
title: "Get Social Media",
|
|
77
|
+
description: "Fetch a social post's media from a TikTok, Instagram, YouTube, X/Twitter, Douyin, Xiaohongshu or Bilibili URL: " +
|
|
78
|
+
"contentType (video/image/carousel/slideshow), title, caption, author, stats and direct media URLs. " +
|
|
79
|
+
"Returns an inline thumbnail image. Consumes 1 orchyn credit.",
|
|
80
|
+
inputSchema: z
|
|
81
|
+
.object({
|
|
82
|
+
url: z.string().describe("Full public post URL."),
|
|
83
|
+
})
|
|
84
|
+
.strict(),
|
|
85
|
+
}, async (args, extra) => {
|
|
86
|
+
const client = await makeClient(extra);
|
|
87
|
+
try {
|
|
88
|
+
return toToolResult(await client.callTool("get_social_media", { url: args.url }));
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
return toolError("get_social_media failed", err);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
server.registerTool("discover_social_posts", {
|
|
95
|
+
title: "Discover Social Posts",
|
|
96
|
+
description: "Discover recent posts (video, image, carousel, slideshow) for a niche on YouTube, TikTok, Instagram, Douyin, Xiaohongshu, X/Twitter or Bilibili via TikHub. " +
|
|
97
|
+
"Each post includes title/caption, thumbnailUrl, externalUrl, views/likes/comments and inline thumbnails (up to 4) so they show in chat. " +
|
|
98
|
+
'Say "next" to paginate (offset), or "analyze the 2nd one" / "analyze all" for batch analysis. Consumes 2 orchyn credits.',
|
|
99
|
+
inputSchema: z
|
|
100
|
+
.object({
|
|
101
|
+
niche: z.string().describe("Niche/topic, e.g. 'fitness'."),
|
|
102
|
+
keywords: z.string().optional().describe("Optional extra keywords."),
|
|
103
|
+
limit: z.number().int().optional().describe("Max results (default 6)."),
|
|
104
|
+
offset: z.number().int().optional().describe("Skip first N results — for 'next' pagination."),
|
|
105
|
+
platform: z
|
|
106
|
+
.enum(["youtube", "tiktok", "instagram", "douyin", "xiaohongshu", "twitter", "bilibili", "any"])
|
|
107
|
+
.optional()
|
|
108
|
+
.describe("Platform to search (default youtube)."),
|
|
109
|
+
})
|
|
110
|
+
.strict(),
|
|
111
|
+
}, async (args, extra) => {
|
|
112
|
+
const client = await makeClient(extra);
|
|
113
|
+
try {
|
|
114
|
+
return toToolResult(await client.callTool("discover_social_posts", { ...args }));
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
return toolError("discover_social_posts failed", err);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
server.registerTool("get_user_posts", {
|
|
121
|
+
title: "Get User Posts",
|
|
122
|
+
description: "List recent posts by a creator handle (e.g. @zoundsapp) via TikHub on TikTok, Instagram, YouTube, Douyin, Xiaohongshu, X/Twitter or Bilibili. " +
|
|
123
|
+
"Each post includes title/caption, thumbnailUrl, externalUrl, views/likes/comments and inline thumbnails (up to 4) so they show in chat. " +
|
|
124
|
+
"Use this when Claude needs to pull more posts from the same account to spot a pattern, or to scan a whole profile. Consumes 2 orchyn credits.",
|
|
125
|
+
inputSchema: z
|
|
126
|
+
.object({
|
|
127
|
+
username: z.string().describe("Creator handle, e.g. 'zoundsapp' or '@zoundsapp'."),
|
|
128
|
+
platform: z
|
|
129
|
+
.enum(["tiktok", "instagram", "youtube", "douyin", "xiaohongshu", "twitter", "bilibili"])
|
|
130
|
+
.optional()
|
|
131
|
+
.describe("Which platform (default tiktok)."),
|
|
132
|
+
limit: z.number().int().optional().describe("Max posts (default 6)."),
|
|
133
|
+
})
|
|
134
|
+
.strict(),
|
|
135
|
+
}, async (args, extra) => {
|
|
136
|
+
const client = await makeClient(extra);
|
|
137
|
+
try {
|
|
138
|
+
return toToolResult(await client.callTool("get_user_posts", { ...args }));
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
return toolError("get_user_posts failed", err);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
server.registerTool("analyze_creator_profile", {
|
|
145
|
+
title: "Analyze Creator Profile",
|
|
146
|
+
description: "Deep-dive a whole creator profile via TikHub on TikTok, Instagram, YouTube, Douyin, Xiaohongshu, X/Twitter or Bilibili: fetch recent posts, run multimodal Gemini on up to 3, " +
|
|
147
|
+
"then synthesize a profile report — creator summary, niche, content themes, hook styles, strengths/weaknesses, " +
|
|
148
|
+
"engagement patterns, audience insights, variation ideas, collaboration fit. Consumes 15 orchyn credits.",
|
|
149
|
+
inputSchema: z
|
|
150
|
+
.object({
|
|
151
|
+
username: z.string().describe("Creator handle, e.g. 'zoundsapp'."),
|
|
152
|
+
platform: z
|
|
153
|
+
.enum(["tiktok", "instagram", "youtube", "douyin", "xiaohongshu", "twitter", "bilibili"])
|
|
154
|
+
.optional()
|
|
155
|
+
.describe("Which platform (default tiktok)."),
|
|
156
|
+
limit: z.number().int().optional().describe("Posts to fetch (default 6; first 3 analyzed)."),
|
|
157
|
+
focus: z.string().optional().describe("Extra instruction for the profile synthesis."),
|
|
158
|
+
})
|
|
159
|
+
.strict(),
|
|
160
|
+
}, async (args, extra) => {
|
|
161
|
+
const client = await makeClient(extra);
|
|
162
|
+
try {
|
|
163
|
+
return toToolResult(await client.callTool("analyze_creator_profile", { ...args }));
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return toolError("analyze_creator_profile failed", err);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
server.registerTool("get_post_comments", {
|
|
170
|
+
title: "Get Post Comments",
|
|
171
|
+
description: "Fetch top comments for a post URL via TikHub on TikTok, Instagram, YouTube, Douyin, X/Twitter or Bilibili, plus keyword clusters from TikTok Analytics " +
|
|
172
|
+
"when available — audience sentiment/audience-signal analysis. Consumes 2 orchyn credits.",
|
|
173
|
+
inputSchema: z
|
|
174
|
+
.object({
|
|
175
|
+
url: z.string().describe("Full public post URL (TikTok/Instagram/YouTube/Douyin/X/Bilibili)."),
|
|
176
|
+
limit: z.number().int().optional().describe("Max comments (default 20)."),
|
|
177
|
+
})
|
|
178
|
+
.strict(),
|
|
179
|
+
}, async (args, extra) => {
|
|
180
|
+
const client = await makeClient(extra);
|
|
181
|
+
try {
|
|
182
|
+
return toToolResult(await client.callTool("get_post_comments", { ...args }));
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
return toolError("get_post_comments failed", err);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
server.registerTool("search_creators", {
|
|
189
|
+
title: "Search Creators",
|
|
190
|
+
description: "Search creators by niche/keyword via TikHub on TikTok, Instagram, Xiaohongshu, YouTube or Douyin — username, nickname, follower count, " +
|
|
191
|
+
"signature, verified status. Use to find influencers to vet or analyze. Consumes 2 orchyn credits.",
|
|
192
|
+
inputSchema: z
|
|
193
|
+
.object({
|
|
194
|
+
keyword: z.string().describe("Niche/keyword, e.g. 'fitness' or a creator name."),
|
|
195
|
+
platform: z
|
|
196
|
+
.enum(["tiktok", "instagram", "xiaohongshu", "youtube", "douyin"])
|
|
197
|
+
.optional()
|
|
198
|
+
.describe("Which platform (default tiktok)."),
|
|
199
|
+
count: z.number().int().optional().describe("Max creators (default 8)."),
|
|
200
|
+
})
|
|
201
|
+
.strict(),
|
|
202
|
+
}, async (args, extra) => {
|
|
203
|
+
const client = await makeClient(extra);
|
|
204
|
+
try {
|
|
205
|
+
return toToolResult(await client.callTool("search_creators", { ...args }));
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
return toolError("search_creators failed", err);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
server.registerTool("get_similar_creators", {
|
|
212
|
+
title: "Get Similar Creators",
|
|
213
|
+
description: "Find lookalike creators for a given handle via TikHub — TikTok similar-user recommendations or Instagram " +
|
|
214
|
+
"similar users. Useful for scaling: 'if this creator works, here are more like them'. Consumes 2 orchyn credits.",
|
|
215
|
+
inputSchema: z
|
|
216
|
+
.object({
|
|
217
|
+
username: z.string().describe("Seed creator handle, e.g. 'zoundsapp'."),
|
|
218
|
+
platform: z.enum(["tiktok", "instagram"]).optional().describe("Which platform (default tiktok)."),
|
|
219
|
+
})
|
|
220
|
+
.strict(),
|
|
221
|
+
}, async (args, extra) => {
|
|
222
|
+
const client = await makeClient(extra);
|
|
223
|
+
try {
|
|
224
|
+
return toToolResult(await client.callTool("get_similar_creators", { ...args }));
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
return toolError("get_similar_creators failed", err);
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
server.registerTool("discover_sounds", {
|
|
231
|
+
title: "Discover Sounds",
|
|
232
|
+
description: "Discover trending sounds/music for a keyword on TikTok or Instagram via TikHub — the sound is a huge ranking " +
|
|
233
|
+
"signal for TikTok virality. Returns title, artist, duration, play/cover URLs. Consumes 2 orchyn credits.",
|
|
234
|
+
inputSchema: z
|
|
235
|
+
.object({
|
|
236
|
+
keyword: z.string().describe("Niche/keyword, e.g. 'gym'."),
|
|
237
|
+
platform: z.enum(["tiktok", "instagram"]).optional().describe("Which platform (default tiktok)."),
|
|
238
|
+
count: z.number().int().optional().describe("Max sounds (default 6)."),
|
|
239
|
+
})
|
|
240
|
+
.strict(),
|
|
241
|
+
}, async (args, extra) => {
|
|
242
|
+
const client = await makeClient(extra);
|
|
243
|
+
try {
|
|
244
|
+
return toToolResult(await client.callTool("discover_sounds", { ...args }));
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
return toolError("discover_sounds failed", err);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
server.registerTool("check_orchyn_credits", {
|
|
251
|
+
title: "Check Orchyn Credits",
|
|
252
|
+
description: "Check your orchyn credit balance, billing URL and pack size. No cost — call anytime to see remaining credits before running other tools.",
|
|
253
|
+
inputSchema: z.object({}).strict(),
|
|
254
|
+
}, async (_args, extra) => {
|
|
255
|
+
const client = await makeClient(extra);
|
|
256
|
+
try {
|
|
257
|
+
return toToolResult(await client.callTool("check_orchyn_credits", {}));
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
return toolError("check_orchyn_credits failed", err);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
server.registerTool("buy_orchyn_credits", {
|
|
264
|
+
title: "Buy Orchyn Credits",
|
|
265
|
+
description: "Buy an MCP credit pack via Stripe Checkout. Returns a secure checkout URL — open it in your browser to pay. Credits are added automatically after payment. No cost to call.",
|
|
266
|
+
inputSchema: z.object({}).strict(),
|
|
267
|
+
}, async (_args, extra) => {
|
|
268
|
+
const client = await makeClient(extra);
|
|
269
|
+
try {
|
|
270
|
+
return toToolResult(await client.callTool("buy_orchyn_credits", {}));
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
return toolError("buy_orchyn_credits failed", err);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
server.registerTool("understand_social_post", {
|
|
277
|
+
title: "Understand Social Post",
|
|
278
|
+
description: "Import a social post URL AND understand it with multimodal AI over the actual video/images: " +
|
|
279
|
+
"summary, hook strength, viral triggers, format breakdown and variation ideas. Includes the thumbnail. " +
|
|
280
|
+
"Supports TikTok, Instagram, YouTube, X/Twitter, Douyin, Xiaohongshu and Bilibili. Consumes 10 orchyn credits.",
|
|
281
|
+
inputSchema: z
|
|
282
|
+
.object({
|
|
283
|
+
url: z.string().describe("Full public post URL (TikTok/Instagram/YouTube/X/Douyin/Xiaohongshu/Bilibili)."),
|
|
284
|
+
focus: z
|
|
285
|
+
.string()
|
|
286
|
+
.optional()
|
|
287
|
+
.describe("Extra instruction, e.g. 'focus on the CTA'."),
|
|
288
|
+
})
|
|
289
|
+
.strict(),
|
|
290
|
+
}, async (args, extra) => {
|
|
291
|
+
const client = await makeClient(extra);
|
|
292
|
+
try {
|
|
293
|
+
return toToolResult(await client.callTool("understand_social_post", { ...args }));
|
|
294
|
+
}
|
|
295
|
+
catch (err) {
|
|
296
|
+
return toolError("understand_social_post failed", err);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
return server;
|
|
300
|
+
}
|