@orchyn/mcp 1.3.4 → 1.4.1

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.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * URL validation + the analyze_post workflow: start the job, then poll until
3
+ * the analysis is done (or fails).
4
+ */
5
+ export const POLL_INTERVAL_MS = 2000;
6
+ export const POLL_TIMEOUT_MS = 300_000;
7
+ const SUPPORTED_HOSTS_VIDEO = new Set([
8
+ "tiktok.com",
9
+ "vm.tiktok.com",
10
+ "m.tiktok.com",
11
+ "instagram.com",
12
+ "instagr.am",
13
+ "youtube.com",
14
+ "youtu.be",
15
+ "m.youtube.com",
16
+ "youtube-nocookie.com",
17
+ "douyin.com",
18
+ "xiaohongshu.com",
19
+ "xhslink.com",
20
+ "bilibili.com",
21
+ "m.bilibili.com",
22
+ "b23.tv",
23
+ ]);
24
+ const SUPPORTED_HOSTS_POST = new Set([
25
+ ...SUPPORTED_HOSTS_VIDEO,
26
+ "x.com",
27
+ "twitter.com",
28
+ "mobile.twitter.com",
29
+ ]);
30
+ function validateUrl(rawUrl, allowed) {
31
+ if (typeof rawUrl !== "string" || rawUrl.trim() === "") {
32
+ return { ok: false, error: "url must be a non-empty string." };
33
+ }
34
+ let parsed;
35
+ try {
36
+ parsed = new URL(rawUrl.trim());
37
+ }
38
+ catch {
39
+ return { ok: false, error: "url is not a valid URL." };
40
+ }
41
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
42
+ return { ok: false, error: "url must use http or https." };
43
+ }
44
+ let host = parsed.hostname.toLowerCase();
45
+ if (host.startsWith("www."))
46
+ host = host.slice(4);
47
+ if (host === "youtube.com" || host === "youtu.be") {
48
+ // accept all youtube.com paths, incl. /shorts/<id>
49
+ return { ok: true, url: parsed.toString() };
50
+ }
51
+ if (allowed.has(host)) {
52
+ return { ok: true, url: parsed.toString() };
53
+ }
54
+ return {
55
+ ok: false,
56
+ error: "url host is not supported. Supported: tiktok.com, instagram.com, youtube.com (and /shorts), x.com, twitter.com, douyin.com, xiaohongshu.com, xhslink.com, bilibili.com, b23.tv.",
57
+ };
58
+ }
59
+ export function validateVideoUrl(rawUrl) {
60
+ return validateUrl(rawUrl, SUPPORTED_HOSTS_VIDEO);
61
+ }
62
+ export function validatePostUrl(rawUrl) {
63
+ return validateUrl(rawUrl, SUPPORTED_HOSTS_POST);
64
+ }
65
+ export class JobTimeoutError extends Error {
66
+ constructor(jobId, elapsedMs, lastStatus) {
67
+ super(`Timed out after ${Math.round(elapsedMs / 1000)}s waiting for analysis job ${jobId} to finish.` +
68
+ (lastStatus?.contentPreview ? ` Partial content: ${lastStatus.contentPreview}` : ""));
69
+ this.name = "JobTimeoutError";
70
+ }
71
+ }
72
+ /**
73
+ * Polls a job until state is "done" or "error".
74
+ */
75
+ export async function pollUntilDone(client, jobId, opts = {}) {
76
+ const pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
77
+ const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
78
+ const startedAt = Date.now();
79
+ let last;
80
+ for (;;) {
81
+ const elapsedMs = Date.now() - startedAt;
82
+ if (elapsedMs >= timeoutMs) {
83
+ throw new JobTimeoutError(jobId, elapsedMs, last);
84
+ }
85
+ const status = await client.getJob(jobId);
86
+ last = status;
87
+ opts.onPoll?.(status);
88
+ if (status.state === "done")
89
+ return status;
90
+ if (status.state === "error")
91
+ return status;
92
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
93
+ }
94
+ }
95
+ /**
96
+ * Full tool workflow: start the analysis, poll until done, return a
97
+ * JSON-serializable result. Throws OrchynError on API-level failures
98
+ * (e.g. 402 paywall).
99
+ */
100
+ export async function runVideoAnalysis(client, url, opts = {}) {
101
+ const job = await client.startVideoAnalysis(url, opts.appId);
102
+ const status = await pollUntilDone(client, job.jobId, opts);
103
+ const result = {
104
+ ok: status.state === "done",
105
+ jobId: status.jobId,
106
+ state: status.state,
107
+ platform: job.platform,
108
+ provider: status.provider ?? job.provider,
109
+ analysis: status.analysis,
110
+ contentPreview: status.contentPreview,
111
+ error: status.error,
112
+ elapsedMs: status.elapsedMs,
113
+ // Mirror the Rust `/mcp` understand_social_post shape: expose the
114
+ // imported post + inline thumbnails at the top level so the analysis
115
+ // tool renders the thumbnail and the full post, not just the analysis.
116
+ post: job.post,
117
+ inlineImages: job.inlineImages,
118
+ };
119
+ if (result.ok) {
120
+ result.job = {
121
+ ok: job.ok,
122
+ jobId: job.jobId,
123
+ state: job.state,
124
+ platform: job.platform,
125
+ provider: job.provider,
126
+ appId: job.appId,
127
+ workspaceId: job.workspaceId,
128
+ cost: job.cost,
129
+ freeGrant: job.freeGrant,
130
+ post: job.post,
131
+ };
132
+ }
133
+ return result;
134
+ }
135
+ export function formatPaywallError(err) {
136
+ const p = err.paywall;
137
+ const parts = [];
138
+ if (p?.reason)
139
+ parts.push(`reason: ${p.reason}`);
140
+ if (p?.used !== undefined && p?.max !== undefined) {
141
+ parts.push(`credits used: ${p.used}/${p.max}`);
142
+ }
143
+ else if (p?.used !== undefined) {
144
+ parts.push(`credits used: ${p.used}`);
145
+ }
146
+ if (p?.cost !== undefined)
147
+ parts.push(`cost: ${p.cost}`);
148
+ const detail = parts.length > 0 ? ` (${parts.join(", ")})` : "";
149
+ return (`Your orchyn account has no credits left for this analysis${detail}. ` +
150
+ `Top up or check your usage in the orchyn dashboard, then try again. ` +
151
+ `Note: the first analysis is covered by the free grant.`);
152
+ }
package/dist/video.js CHANGED
@@ -1,141 +1,6 @@
1
1
  /**
2
- * URL validation + the analyze_video workflow: start the job, then poll until
3
- * the analysis is done (or fails).
2
+ * URL validation + analysis workflow helpers now live in `shared/video.ts`
3
+ * (shared with the Cloudflare Worker). This file re-exports them for
4
+ * backward compatibility.
4
5
  */
5
- export const POLL_INTERVAL_MS = 2000;
6
- export const POLL_TIMEOUT_MS = 300_000;
7
- const SUPPORTED_HOSTS_VIDEO = new Set([
8
- "tiktok.com",
9
- "vm.tiktok.com",
10
- "instagram.com",
11
- "instagr.am",
12
- "youtube.com",
13
- "youtu.be",
14
- "m.youtube.com",
15
- "youtube-nocookie.com",
16
- "m.tiktok.com",
17
- ]);
18
- const SUPPORTED_HOSTS_POST = new Set([
19
- ...SUPPORTED_HOSTS_VIDEO,
20
- "x.com",
21
- "twitter.com",
22
- "mobile.twitter.com",
23
- ]);
24
- function validateUrl(rawUrl, allowed) {
25
- if (typeof rawUrl !== "string" || rawUrl.trim() === "") {
26
- return { ok: false, error: "url must be a non-empty string." };
27
- }
28
- let parsed;
29
- try {
30
- parsed = new URL(rawUrl.trim());
31
- }
32
- catch {
33
- return { ok: false, error: "url is not a valid URL." };
34
- }
35
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
36
- return { ok: false, error: "url must use http or https." };
37
- }
38
- let host = parsed.hostname.toLowerCase();
39
- if (host.startsWith("www."))
40
- host = host.slice(4);
41
- if (host === "youtube.com" || host === "youtu.be") {
42
- // accept all youtube.com paths, incl. /shorts/<id>
43
- return { ok: true, url: parsed.toString() };
44
- }
45
- if (allowed.has(host)) {
46
- return { ok: true, url: parsed.toString() };
47
- }
48
- return {
49
- ok: false,
50
- error: "url host is not supported. Supported: tiktok.com, vm.tiktok.com, instagram.com, instagr.am, youtube.com, youtu.be, m.youtube.com (and /shorts).",
51
- };
52
- }
53
- export function validateVideoUrl(rawUrl) {
54
- return validateUrl(rawUrl, SUPPORTED_HOSTS_VIDEO);
55
- }
56
- export function validatePostUrl(rawUrl) {
57
- return validateUrl(rawUrl, SUPPORTED_HOSTS_POST);
58
- }
59
- export class JobTimeoutError extends Error {
60
- constructor(jobId, elapsedMs, lastStatus) {
61
- super(`Timed out after ${Math.round(elapsedMs / 1000)}s waiting for analysis job ${jobId} to finish.` +
62
- (lastStatus?.contentPreview ? ` Partial content: ${lastStatus.contentPreview}` : ""));
63
- this.name = "JobTimeoutError";
64
- }
65
- }
66
- /**
67
- * Polls a job until state is "done" or "error".
68
- */
69
- export async function pollUntilDone(client, jobId, opts = {}) {
70
- const pollIntervalMs = opts.pollIntervalMs ?? POLL_INTERVAL_MS;
71
- const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
72
- const startedAt = Date.now();
73
- let last;
74
- for (;;) {
75
- const elapsedMs = Date.now() - startedAt;
76
- if (elapsedMs >= timeoutMs) {
77
- throw new JobTimeoutError(jobId, elapsedMs, last);
78
- }
79
- const status = await client.getJob(jobId);
80
- last = status;
81
- opts.onPoll?.(status);
82
- if (status.state === "done")
83
- return status;
84
- if (status.state === "error")
85
- return status;
86
- await new Promise((r) => setTimeout(r, pollIntervalMs));
87
- }
88
- }
89
- /**
90
- * Full tool workflow: start the analysis, poll until done, return a
91
- * JSON-serializable result. Throws OrchynError on API-level failures
92
- * (e.g. 402 paywall).
93
- */
94
- export async function runVideoAnalysis(client, url, opts = {}) {
95
- const job = await client.startVideoAnalysis(url, opts.appId);
96
- const status = await pollUntilDone(client, job.jobId, opts);
97
- const result = {
98
- ok: status.state === "done",
99
- jobId: status.jobId,
100
- state: status.state,
101
- platform: job.platform,
102
- provider: status.provider ?? job.provider,
103
- analysis: status.analysis,
104
- contentPreview: status.contentPreview,
105
- error: status.error,
106
- elapsedMs: status.elapsedMs,
107
- };
108
- if (result.ok) {
109
- result.job = {
110
- ok: job.ok,
111
- jobId: job.jobId,
112
- state: job.state,
113
- platform: job.platform,
114
- provider: job.provider,
115
- appId: job.appId,
116
- workspaceId: job.workspaceId,
117
- cost: job.cost,
118
- freeGrant: job.freeGrant,
119
- post: job.post,
120
- };
121
- }
122
- return result;
123
- }
124
- export function formatPaywallError(err) {
125
- const p = err.paywall;
126
- const parts = [];
127
- if (p?.reason)
128
- parts.push(`reason: ${p.reason}`);
129
- if (p?.used !== undefined && p?.max !== undefined) {
130
- parts.push(`credits used: ${p.used}/${p.max}`);
131
- }
132
- else if (p?.used !== undefined) {
133
- parts.push(`credits used: ${p.used}`);
134
- }
135
- if (p?.cost !== undefined)
136
- parts.push(`cost: ${p.cost}`);
137
- const detail = parts.length > 0 ? ` (${parts.join(", ")})` : "";
138
- return (`Your orchyn account has no credits left for this analysis${detail}. ` +
139
- `Top up or check your usage in the orchyn dashboard, then try again. ` +
140
- `Note: the first analysis is covered by the free grant.`);
141
- }
6
+ export * from "./shared/video.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchyn/mcp",
3
- "version": "1.3.4",
3
+ "version": "1.4.1",
4
4
  "description": "MCP server for orchyn - fetch, discover and understand TikTok/Instagram/YouTube/X posts with AI (media metadata, niche discovery, hook & viral analysis)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,7 +23,15 @@
23
23
  "test": "vitest run",
24
24
  "prepare": "npm run build"
25
25
  },
26
- "keywords": ["mcp", "orchyn", "video", "analysis", "tiktok", "instagram", "youtube"],
26
+ "keywords": [
27
+ "mcp",
28
+ "orchyn",
29
+ "video",
30
+ "analysis",
31
+ "tiktok",
32
+ "instagram",
33
+ "youtube"
34
+ ],
27
35
  "license": "MIT",
28
36
  "dependencies": {
29
37
  "@modelcontextprotocol/sdk": "^1.16.0",