@orchyn/mcp 1.10.0 → 1.12.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.
@@ -10,12 +10,59 @@ import { z } from "zod";
10
10
  import { OrchynError } from "./orchyn.js";
11
11
  import { formatPaywallError, runVideoAnalysis, validatePostUrl } from "./video.js";
12
12
  import { ORCHYN_UI_TEMPLATE } from "./ui-template.js";
13
+ /** Current MCP server version — bumped on every deploy for traceability. */
14
+ export const MCP_SERVER_VERSION = "1.11.0";
13
15
  /** MCP Apps extension identifier */
14
16
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
15
17
  /** MIME type for MCP Apps HTML resources */
16
18
  const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
17
19
  /** Single UI resource URI shared by all tools */
18
20
  const UI_RESOURCE_URI = "ui://orchyn/view";
21
+ /**
22
+ * Rewrite external image URLs to go through the orchyn proxy so they
23
+ * work inside ChatGPT's sandboxed iframe (CORS + CSP restrictions).
24
+ */
25
+ function proxyImageUrl(url) {
26
+ if (!url || url.startsWith("data:"))
27
+ return url;
28
+ try {
29
+ const u = new URL(url);
30
+ // Only proxy external HTTP(S) URLs — skip our own proxy and data URIs
31
+ if (u.protocol === "http:" || u.protocol === "https:") {
32
+ const serverUrl = process.env.ORCHYN_API_URL || process.env.ORCHYN_BASE_URL || "";
33
+ if (serverUrl && !url.startsWith(serverUrl)) {
34
+ return `${serverUrl.replace(/\/+$/, "")}/media/proxy?url=${encodeURIComponent(url)}`;
35
+ }
36
+ }
37
+ }
38
+ catch { }
39
+ return url;
40
+ }
41
+ /** Recursively rewrite URL fields in a structured object for the proxy. */
42
+ function proxyUrls(obj) {
43
+ if (typeof obj === "string") {
44
+ // Check if it looks like a URL
45
+ if (/^https?:\/\//.test(obj) && !obj.includes("/media/proxy")) {
46
+ return proxyImageUrl(obj);
47
+ }
48
+ return obj;
49
+ }
50
+ if (Array.isArray(obj))
51
+ return obj.map(proxyUrls);
52
+ if (obj && typeof obj === "object") {
53
+ const out = {};
54
+ for (const [k, v] of Object.entries(obj)) {
55
+ if (["thumbnailUrl", "preview_url", "coverUrl", "avatarUrl", "avatar_thumb", "image_url", "url"].includes(k) && typeof v === "string") {
56
+ out[k] = proxyImageUrl(v);
57
+ }
58
+ else {
59
+ out[k] = proxyUrls(v);
60
+ }
61
+ }
62
+ return out;
63
+ }
64
+ return obj;
65
+ }
19
66
  function toToolResult(proxy) {
20
67
  const images = proxy.contentBlocks
21
68
  .filter((c) => c.type === "image")
@@ -33,19 +80,110 @@ function toToolResult(proxy) {
33
80
  ? rawText.substring(0, rawText.indexOf("\n\n{")).trimEnd()
34
81
  : "";
35
82
  const structured = proxy.structured;
36
- const textJson = JSON.stringify(structured ?? {}, null, 2);
37
- const text = htmlPrefix ? `${htmlPrefix}\n\n${textJson}` : textJson;
83
+ // Proxy thumbnail URLs in structured content for ChatGPT iframe
84
+ const proxied = structured ? proxyUrls(structured) : {};
85
+ const textJson = JSON.stringify(proxied ?? {}, null, 2);
86
+ // Replace image URLs in HTML with proxied versions
87
+ const proxiedHtml = htmlPrefix ? proxyImageUrlsInHtml(htmlPrefix) : "";
88
+ const text = proxiedHtml ? `${proxiedHtml}\n\n${textJson}` : textJson;
38
89
  return {
39
90
  content: [...images, { type: "text", text }],
40
- structuredContent: structured ?? {},
91
+ structuredContent: proxied ?? {},
41
92
  };
42
93
  }
94
+ /** Replace external image src/href URLs in HTML with proxied versions. */
95
+ function proxyImageUrlsInHtml(html) {
96
+ return html.replace(/(src|href)="(https?:\/\/[^"']+?)"/g, (_match, attr, url) => {
97
+ return `${attr}="${proxyImageUrl(url)}"`;
98
+ });
99
+ }
43
100
  function toolError(prefix, err) {
44
101
  const msg = err instanceof Error ? err.message : String(err);
45
102
  return { content: [{ type: "text", text: `${prefix}: ${msg}` }], isError: true };
46
103
  }
104
+ /** Build an HTML card for analyze_post results. */
105
+ function buildAnalysisHtmlCard(result, thumbnailUrl, platform, creatorHandle, title) {
106
+ const platformEmoji = {
107
+ tiktok: "🎵", instagram: "📸", youtube: "▶️", twitter: "🐦",
108
+ x: "🐦", douyin: "🎵", xiaohongshu: "📕", bilibili: "📺",
109
+ };
110
+ const platformColor = {
111
+ tiktok: "#000000", instagram: "#E4405F", youtube: "#FF0000",
112
+ twitter: "#1DA1F2", x: "#000000", douyin: "#000000",
113
+ xiaohongshu: "#FF2442", bilibili: "#00A1D6",
114
+ };
115
+ const emoji = platformEmoji[platform] || "📱";
116
+ const color = platformColor[platform] || "#6b7280";
117
+ const post = (result.post ?? {});
118
+ const analysis = (result.analysis ?? {});
119
+ const views = typeof post.views === "number" ? post.views.toLocaleString() : "—";
120
+ const likes = typeof post.likes === "number" ? post.likes.toLocaleString() : "—";
121
+ const comments = typeof post.comments === "number" ? post.comments.toLocaleString() : "—";
122
+ const shares = typeof post.shares === "number" ? post.shares.toLocaleString() : "—";
123
+ const hookStrength = typeof analysis.hookStrength === "number" ? analysis.hookStrength : null;
124
+ const summary = typeof analysis.summary === "string" ? analysis.summary : "";
125
+ const whyItWorks = typeof analysis.whyItWorks === "string" ? analysis.whyItWorks : "";
126
+ const viralTriggers = Array.isArray(analysis.viralTriggers) ? analysis.viralTriggers : [];
127
+ const variationIdeas = Array.isArray(analysis.variationIdeas) ? analysis.variationIdeas.slice(0, 3) : [];
128
+ const suggestedHook = typeof analysis.suggestedHook === "string" ? analysis.suggestedHook : "";
129
+ let html = `<div style="font-family:system-ui,-apple-system,sans-serif;max-width:600px;background:#0d1117;color:#e6edf3;border-radius:12px;overflow:hidden;margin:0 auto">`;
130
+ // Thumbnail
131
+ if (thumbnailUrl) {
132
+ html += `<div style="position:relative"><img src="${thumbnailUrl}" style="width:100%;max-height:400px;object-fit:cover;display:block" onerror="this.style.display='none'" /><div style="position:absolute;bottom:12px;left:12px;display:flex;gap:8px;flex-wrap:wrap">`;
133
+ html += `<span style="background:${color};color:#fff;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:600">${emoji} ${platform}</span>`;
134
+ html += `</div></div>`;
135
+ }
136
+ // Header
137
+ html += `<div style="padding:16px 20px">`;
138
+ html += `<div style="font-size:18px;font-weight:700;margin-bottom:4px">📊 AI Post Analysis</div>`;
139
+ if (creatorHandle)
140
+ html += `<div style="color:#8b949e;font-size:13px">@${creatorHandle}</div>`;
141
+ if (title)
142
+ html += `<div style="color:#c9d1d9;font-size:14px;margin-top:8px;line-height:1.4">${title.slice(0, 150)}</div>`;
143
+ // Stats
144
+ html += `<div style="display:flex;gap:12px;margin-top:12px;flex-wrap:wrap">`;
145
+ html += `<span style="background:#21262d;padding:4px 10px;border-radius:8px;font-size:13px">👁️ ${views}</span>`;
146
+ html += `<span style="background:#21262d;padding:4px 10px;border-radius:8px;font-size:13px">❤️ ${likes}</span>`;
147
+ html += `<span style="background:#21262d;padding:4px 10px;border-radius:8px;font-size:13px">💬 ${comments}</span>`;
148
+ html += `<span style="background:#21262d;padding:4px 10px;border-radius:8px;font-size:13px">🔄 ${shares}</span>`;
149
+ html += `</div>`;
150
+ // Hook strength bar
151
+ if (hookStrength !== null) {
152
+ const pct = Math.min(hookStrength * 10, 100);
153
+ const barColor = hookStrength >= 7 ? "#3fb950" : hookStrength >= 4 ? "#d29922" : "#f85149";
154
+ html += `<div style="margin-top:12px"><div style="font-size:12px;color:#8b949e;margin-bottom:4px">Hook Strength: ${hookStrength}/10</div><div style="background:#21262d;border-radius:999px;height:8px"><div style="background:${barColor};width:${pct}%;height:100%;border-radius:999px"></div></div></div>`;
155
+ }
156
+ // Summary
157
+ if (summary) {
158
+ html += `<div style="margin-top:14px;padding:12px;background:#161b22;border-radius:8px;border-left:3px solid ${color}"><div style="font-size:12px;font-weight:600;color:#8b949e;margin-bottom:4px">📝 Summary</div><div style="font-size:13px;line-height:1.5">${summary}</div></div>`;
159
+ }
160
+ // Why it works
161
+ if (whyItWorks) {
162
+ html += `<div style="margin-top:10px;padding:12px;background:#161b22;border-radius:8px;border-left:3px solid #3fb950"><div style="font-size:12px;font-weight:600;color:#8b949e;margin-bottom:4px">✅ Why It Works</div><div style="font-size:13px;line-height:1.5">${whyItWorks}</div></div>`;
163
+ }
164
+ // Viral triggers
165
+ if (viralTriggers.length > 0) {
166
+ html += `<div style="margin-top:10px"><div style="font-size:12px;font-weight:600;color:#8b949e;margin-bottom:6px">🔥 Viral Triggers</div><div style="display:flex;flex-wrap:wrap;gap:6px">`;
167
+ viralTriggers.forEach((t) => {
168
+ html += `<span style="background:#1f6feb33;color:#58a6ff;padding:4px 10px;border-radius:999px;font-size:12px">${t}</span>`;
169
+ });
170
+ html += `</div></div>`;
171
+ }
172
+ // Suggested hook
173
+ if (suggestedHook) {
174
+ html += `<div style="margin-top:10px;padding:12px;background:#161b22;border-radius:8px;border-left:3px solid #d29922"><div style="font-size:12px;font-weight:600;color:#8b949e;margin-bottom:4px">💡 Suggested Hook</div><div style="font-size:13px;font-style:italic;line-height:1.5">${suggestedHook}</div></div>`;
175
+ }
176
+ // Variation ideas
177
+ if (variationIdeas.length > 0) {
178
+ html += `<div style="margin-top:10px"><div style="font-size:12px;font-weight:600;color:#8b949e;margin-bottom:6px">🔄 Variation Ideas</div><ol style="margin:0;padding-left:18px;font-size:13px;line-height:1.6">`;
179
+ variationIdeas.forEach((v) => { html += `<li>${v}</li>`; });
180
+ html += `</ol></div>`;
181
+ }
182
+ html += `</div></div>`;
183
+ return html;
184
+ }
47
185
  export function createMcpServer(makeClient) {
48
- const server = new McpServer({ name: "orchyn-mcp", version: "1.8.0" }, {
186
+ const server = new McpServer({ name: "orchyn-mcp", version: MCP_SERVER_VERSION }, {
49
187
  capabilities: {
50
188
  resources: {},
51
189
  extensions: {
@@ -107,15 +245,27 @@ export function createMcpServer(makeClient) {
107
245
  }
108
246
  try {
109
247
  const result = await runVideoAnalysis(client, validation.url);
248
+ // Remove internal field from the JSON shown to the model
249
+ const { inlineImages: _omit, ...rest } = result;
250
+ // Proxy thumbnail URLs in structured content for ChatGPT iframe
251
+ const proxied = proxyUrls(rest);
252
+ // Build HTML card for interactive UI
253
+ const post = (rest.post ?? {});
254
+ const analysis = (rest.analysis ?? {});
255
+ const thumbnailUrl = typeof post.thumbnailUrl === "string" ? proxyImageUrl(post.thumbnailUrl) : "";
256
+ const platform = String(post.platform ?? rest.platform ?? "");
257
+ const creatorHandle = String(post.creatorHandle ?? "");
258
+ const title = String(post.title ?? post.caption ?? "");
259
+ const htmlCard = buildAnalysisHtmlCard(proxied, thumbnailUrl, platform, creatorHandle, title);
110
260
  const images = (result.inlineImages ?? []).map((img) => ({
111
261
  type: "image",
112
262
  data: String(img.data),
113
263
  mimeType: String(img.mimeType ?? "image/jpeg"),
114
264
  }));
115
- // Remove internal thumbnail field from the JSON shown to the model
116
- const { inlineImages: _omit, ...rest } = result;
265
+ const textJson = JSON.stringify(proxied, null, 2);
117
266
  return {
118
- content: [...images, { type: "text", text: JSON.stringify(rest, null, 2) }],
267
+ content: [...images, { type: "text", text: `${htmlCard}\n\n${textJson}` }],
268
+ structuredContent: proxied,
119
269
  };
120
270
  }
121
271
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchyn/mcp",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
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",