@orchyn/mcp 1.9.1 → 1.11.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.
Files changed (2) hide show
  1. package/dist/shared/tools.js +183 -19
  2. package/package.json +1 -1
@@ -16,6 +16,51 @@ const UI_EXTENSION = "io.modelcontextprotocol/ui";
16
16
  const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
17
17
  /** Single UI resource URI shared by all tools */
18
18
  const UI_RESOURCE_URI = "ui://orchyn/view";
19
+ /**
20
+ * Rewrite external image URLs to go through the orchyn proxy so they
21
+ * work inside ChatGPT's sandboxed iframe (CORS + CSP restrictions).
22
+ */
23
+ function proxyImageUrl(url) {
24
+ if (!url || url.startsWith("data:"))
25
+ return url;
26
+ try {
27
+ const u = new URL(url);
28
+ // Only proxy external HTTP(S) URLs — skip our own proxy and data URIs
29
+ if (u.protocol === "http:" || u.protocol === "https:") {
30
+ const serverUrl = process.env.ORCHYN_API_URL || process.env.ORCHYN_BASE_URL || "";
31
+ if (serverUrl && !url.startsWith(serverUrl)) {
32
+ return `${serverUrl.replace(/\/+$/, "")}/media/proxy?url=${encodeURIComponent(url)}`;
33
+ }
34
+ }
35
+ }
36
+ catch { }
37
+ return url;
38
+ }
39
+ /** Recursively rewrite URL fields in a structured object for the proxy. */
40
+ function proxyUrls(obj) {
41
+ if (typeof obj === "string") {
42
+ // Check if it looks like a URL
43
+ if (/^https?:\/\//.test(obj) && !obj.includes("/media/proxy")) {
44
+ return proxyImageUrl(obj);
45
+ }
46
+ return obj;
47
+ }
48
+ if (Array.isArray(obj))
49
+ return obj.map(proxyUrls);
50
+ if (obj && typeof obj === "object") {
51
+ const out = {};
52
+ for (const [k, v] of Object.entries(obj)) {
53
+ if (["thumbnailUrl", "preview_url", "coverUrl", "avatarUrl", "avatar_thumb", "image_url", "url"].includes(k) && typeof v === "string") {
54
+ out[k] = proxyImageUrl(v);
55
+ }
56
+ else {
57
+ out[k] = proxyUrls(v);
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ return obj;
63
+ }
19
64
  function toToolResult(proxy) {
20
65
  const images = proxy.contentBlocks
21
66
  .filter((c) => c.type === "image")
@@ -33,14 +78,108 @@ function toToolResult(proxy) {
33
78
  ? rawText.substring(0, rawText.indexOf("\n\n{")).trimEnd()
34
79
  : "";
35
80
  const structured = proxy.structured;
36
- const textJson = JSON.stringify(structured ?? {}, null, 2);
37
- const text = htmlPrefix ? `${htmlPrefix}\n\n${textJson}` : textJson;
38
- return { content: [...images, { type: "text", text }] };
81
+ // Proxy thumbnail URLs in structured content for ChatGPT iframe
82
+ const proxied = structured ? proxyUrls(structured) : {};
83
+ const textJson = JSON.stringify(proxied ?? {}, null, 2);
84
+ // Replace image URLs in HTML with proxied versions
85
+ const proxiedHtml = htmlPrefix ? proxyImageUrlsInHtml(htmlPrefix) : "";
86
+ const text = proxiedHtml ? `${proxiedHtml}\n\n${textJson}` : textJson;
87
+ return {
88
+ content: [...images, { type: "text", text }],
89
+ structuredContent: proxied ?? {},
90
+ };
91
+ }
92
+ /** Replace external image src/href URLs in HTML with proxied versions. */
93
+ function proxyImageUrlsInHtml(html) {
94
+ return html.replace(/(src|href)="(https?:\/\/[^"']+?)"/g, (_match, attr, url) => {
95
+ return `${attr}="${proxyImageUrl(url)}"`;
96
+ });
39
97
  }
40
98
  function toolError(prefix, err) {
41
99
  const msg = err instanceof Error ? err.message : String(err);
42
100
  return { content: [{ type: "text", text: `${prefix}: ${msg}` }], isError: true };
43
101
  }
102
+ /** Build an HTML card for analyze_post results. */
103
+ function buildAnalysisHtmlCard(result, thumbnailUrl, platform, creatorHandle, title) {
104
+ const platformEmoji = {
105
+ tiktok: "🎵", instagram: "📸", youtube: "▶️", twitter: "🐦",
106
+ x: "🐦", douyin: "🎵", xiaohongshu: "📕", bilibili: "📺",
107
+ };
108
+ const platformColor = {
109
+ tiktok: "#000000", instagram: "#E4405F", youtube: "#FF0000",
110
+ twitter: "#1DA1F2", x: "#000000", douyin: "#000000",
111
+ xiaohongshu: "#FF2442", bilibili: "#00A1D6",
112
+ };
113
+ const emoji = platformEmoji[platform] || "📱";
114
+ const color = platformColor[platform] || "#6b7280";
115
+ const post = (result.post ?? {});
116
+ const analysis = (result.analysis ?? {});
117
+ const views = typeof post.views === "number" ? post.views.toLocaleString() : "—";
118
+ const likes = typeof post.likes === "number" ? post.likes.toLocaleString() : "—";
119
+ const comments = typeof post.comments === "number" ? post.comments.toLocaleString() : "—";
120
+ const shares = typeof post.shares === "number" ? post.shares.toLocaleString() : "—";
121
+ const hookStrength = typeof analysis.hookStrength === "number" ? analysis.hookStrength : null;
122
+ const summary = typeof analysis.summary === "string" ? analysis.summary : "";
123
+ const whyItWorks = typeof analysis.whyItWorks === "string" ? analysis.whyItWorks : "";
124
+ const viralTriggers = Array.isArray(analysis.viralTriggers) ? analysis.viralTriggers : [];
125
+ const variationIdeas = Array.isArray(analysis.variationIdeas) ? analysis.variationIdeas.slice(0, 3) : [];
126
+ const suggestedHook = typeof analysis.suggestedHook === "string" ? analysis.suggestedHook : "";
127
+ 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">`;
128
+ // Thumbnail
129
+ if (thumbnailUrl) {
130
+ 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">`;
131
+ html += `<span style="background:${color};color:#fff;padding:4px 10px;border-radius:999px;font-size:12px;font-weight:600">${emoji} ${platform}</span>`;
132
+ html += `</div></div>`;
133
+ }
134
+ // Header
135
+ html += `<div style="padding:16px 20px">`;
136
+ html += `<div style="font-size:18px;font-weight:700;margin-bottom:4px">📊 AI Post Analysis</div>`;
137
+ if (creatorHandle)
138
+ html += `<div style="color:#8b949e;font-size:13px">@${creatorHandle}</div>`;
139
+ if (title)
140
+ html += `<div style="color:#c9d1d9;font-size:14px;margin-top:8px;line-height:1.4">${title.slice(0, 150)}</div>`;
141
+ // Stats
142
+ html += `<div style="display:flex;gap:12px;margin-top:12px;flex-wrap:wrap">`;
143
+ html += `<span style="background:#21262d;padding:4px 10px;border-radius:8px;font-size:13px">👁️ ${views}</span>`;
144
+ html += `<span style="background:#21262d;padding:4px 10px;border-radius:8px;font-size:13px">❤️ ${likes}</span>`;
145
+ html += `<span style="background:#21262d;padding:4px 10px;border-radius:8px;font-size:13px">💬 ${comments}</span>`;
146
+ html += `<span style="background:#21262d;padding:4px 10px;border-radius:8px;font-size:13px">🔄 ${shares}</span>`;
147
+ html += `</div>`;
148
+ // Hook strength bar
149
+ if (hookStrength !== null) {
150
+ const pct = Math.min(hookStrength * 10, 100);
151
+ const barColor = hookStrength >= 7 ? "#3fb950" : hookStrength >= 4 ? "#d29922" : "#f85149";
152
+ 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>`;
153
+ }
154
+ // Summary
155
+ if (summary) {
156
+ 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>`;
157
+ }
158
+ // Why it works
159
+ if (whyItWorks) {
160
+ 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>`;
161
+ }
162
+ // Viral triggers
163
+ if (viralTriggers.length > 0) {
164
+ 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">`;
165
+ viralTriggers.forEach((t) => {
166
+ html += `<span style="background:#1f6feb33;color:#58a6ff;padding:4px 10px;border-radius:999px;font-size:12px">${t}</span>`;
167
+ });
168
+ html += `</div></div>`;
169
+ }
170
+ // Suggested hook
171
+ if (suggestedHook) {
172
+ 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>`;
173
+ }
174
+ // Variation ideas
175
+ if (variationIdeas.length > 0) {
176
+ 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">`;
177
+ variationIdeas.forEach((v) => { html += `<li>${v}</li>`; });
178
+ html += `</ol></div>`;
179
+ }
180
+ html += `</div></div>`;
181
+ return html;
182
+ }
44
183
  export function createMcpServer(makeClient) {
45
184
  const server = new McpServer({ name: "orchyn-mcp", version: "1.8.0" }, {
46
185
  capabilities: {
@@ -53,22 +192,35 @@ export function createMcpServer(makeClient) {
53
192
  // Register the single UI resource that all tools share.
54
193
  // The host reads this resource, renders it in a sandboxed iframe,
55
194
  // and pushes tool results via ui/notifications/tool-result.
56
- server.registerResource("Orchyn Interactive View", UI_RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
57
- contents: [
58
- {
59
- uri: UI_RESOURCE_URI,
60
- mimeType: RESOURCE_MIME_TYPE,
61
- text: ORCHYN_UI_TEMPLATE,
62
- _meta: {
63
- ui: {
64
- csp: {
65
- resourceDomains: ["https://*.tiktokcdn.com", "https://*.cdninstagram.com", "https://*.ytimg.com", "https://*.googlevideo.com"],
195
+ server.registerResource("Orchyn Interactive View", UI_RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => {
196
+ // Build CSP resourceDomains from env so proxied thumbnails work
197
+ const domains = [
198
+ "https://*.tiktokcdn.com",
199
+ "https://*.cdninstagram.com",
200
+ "https://*.ytimg.com",
201
+ "https://*.googlevideo.com",
202
+ ];
203
+ const apiUrl = process.env.ORCHYN_API_URL || process.env.ORCHYN_BASE_URL;
204
+ if (apiUrl && apiUrl.trim()) {
205
+ domains.push(apiUrl.trim().replace(/\/+$/, ""));
206
+ }
207
+ return {
208
+ contents: [
209
+ {
210
+ uri: UI_RESOURCE_URI,
211
+ mimeType: RESOURCE_MIME_TYPE,
212
+ text: ORCHYN_UI_TEMPLATE,
213
+ _meta: {
214
+ ui: {
215
+ csp: {
216
+ resourceDomains: domains,
217
+ },
66
218
  },
67
219
  },
68
220
  },
69
- },
70
- ],
71
- }));
221
+ ],
222
+ };
223
+ });
72
224
  server.registerTool("analyze_post", {
73
225
  title: "Analyze Post",
74
226
  description: "Analyze a social post (video, image, carousel/slideshow) from its link — " +
@@ -91,15 +243,27 @@ export function createMcpServer(makeClient) {
91
243
  }
92
244
  try {
93
245
  const result = await runVideoAnalysis(client, validation.url);
246
+ // Remove internal field from the JSON shown to the model
247
+ const { inlineImages: _omit, ...rest } = result;
248
+ // Proxy thumbnail URLs in structured content for ChatGPT iframe
249
+ const proxied = proxyUrls(rest);
250
+ // Build HTML card for interactive UI
251
+ const post = (rest.post ?? {});
252
+ const analysis = (rest.analysis ?? {});
253
+ const thumbnailUrl = typeof post.thumbnailUrl === "string" ? proxyImageUrl(post.thumbnailUrl) : "";
254
+ const platform = String(post.platform ?? rest.platform ?? "");
255
+ const creatorHandle = String(post.creatorHandle ?? "");
256
+ const title = String(post.title ?? post.caption ?? "");
257
+ const htmlCard = buildAnalysisHtmlCard(proxied, thumbnailUrl, platform, creatorHandle, title);
94
258
  const images = (result.inlineImages ?? []).map((img) => ({
95
259
  type: "image",
96
260
  data: String(img.data),
97
261
  mimeType: String(img.mimeType ?? "image/jpeg"),
98
262
  }));
99
- // Remove internal thumbnail field from the JSON shown to the model
100
- const { inlineImages: _omit, ...rest } = result;
263
+ const textJson = JSON.stringify(proxied, null, 2);
101
264
  return {
102
- content: [...images, { type: "text", text: JSON.stringify(rest, null, 2) }],
265
+ content: [...images, { type: "text", text: `${htmlCard}\n\n${textJson}` }],
266
+ structuredContent: proxied,
103
267
  };
104
268
  }
105
269
  catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchyn/mcp",
3
- "version": "1.9.1",
3
+ "version": "1.11.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",