@orchyn/mcp 1.14.0 → 1.16.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.
@@ -11,13 +11,23 @@ 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
13
  /** Current MCP server version — bumped on every deploy for traceability. */
14
- export const MCP_SERVER_VERSION = "1.14.0";
14
+ export const MCP_SERVER_VERSION = "1.16.0";
15
15
  /** MCP Apps extension identifier */
16
16
  const UI_EXTENSION = "io.modelcontextprotocol/ui";
17
17
  /** MIME type for MCP Apps HTML resources */
18
18
  const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
19
- /** Single UI resource URI shared by all tools */
19
+ /** Default UI resource URI (only used when a tool has no explicit view). */
20
20
  const UI_RESOURCE_URI = "ui://orchyn/view";
21
+ /**
22
+ * Distinct UI resource URI per tool/view. Claude/ChatGPT create one app
23
+ * instance per resourceUri and key app state by it (ext-apps#558), so giving
24
+ * each tool its own uri avoids a shared app instance / session colliding
25
+ * between different tools and lets each view complete its own handshake.
26
+ */
27
+ function uiResource(tool) {
28
+ const slug = tool.replace(/[^a-z0-9_]/gi, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
29
+ return `ui://orchyn/${slug || "view"}`;
30
+ }
21
31
  /**
22
32
  * claude.ai requires `ui.domain` on the resource == sha256("<MCP endpoint
23
33
  * URL>")[:32] + ".claudemcpcontent.com" — the iframe is only revealed on
@@ -41,24 +51,6 @@ async function computeAppDomain() {
41
51
  return undefined;
42
52
  }
43
53
  }
44
- /** Convert an arbitrary http(s) URL to a base64 MCP `image` data block. */
45
- async function fetchAsBase64Image(url, mimeType) {
46
- try {
47
- const res = await fetch(url, { signal: AbortSignal.timeout(12_000) });
48
- if (!res.ok)
49
- return { type: "image", data: "", mimeType };
50
- const buf = await res.arrayBuffer();
51
- const bytes = new Uint8Array(buf);
52
- let bin = "";
53
- for (let i = 0; i < bytes.length; i += 0x8000) {
54
- bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
55
- }
56
- return { type: "image", data: btoa(bin), mimeType };
57
- }
58
- catch {
59
- return { type: "image", data: "", mimeType };
60
- }
61
- }
62
54
  /**
63
55
  * Rewrite external image URLs to go through the orchyn proxy so they
64
56
  * work inside ChatGPT's sandboxed iframe (CORS + CSP restrictions).
@@ -105,19 +97,12 @@ function proxyUrls(obj) {
105
97
  return obj;
106
98
  }
107
99
  async function toToolResult(proxy) {
108
- // The MCP SDK's ImageContent only accepts `data` (base64). The orchyn
109
- // backend now emits `url` image blocks pointing at permanent, re-hosted
110
- // JPEG thumbnails (HEIC already transcoded), so convert those URLs to
111
- // base64 data here guaranteeing clients render a valid image.
112
- const imgBlocks = proxy.contentBlocks.filter((c) => c.type === "image");
113
- // Skip image blocks whose base64 is empty (fetch failed) — clients reject
114
- // them with "could not be processed: Error processing image".
115
- const images = (await Promise.all(imgBlocks.map((c) => c.url
116
- ? fetchAsBase64Image(String(c.url), String(c.mimeType ?? "image/jpeg"))
117
- : Promise.resolve({ type: "image", data: String(c.data ?? ""), mimeType: String(c.mimeType ?? "image/jpeg") })))).filter((img) => img.type === "image" && img.data.length > 0);
118
- // The Rust backend embeds HTML cards directly in the text block
119
- // (type:"text", text:"<div>...</div>\n\n{json}"). We extract the HTML
120
- // prefix from the first text contentBlock and prepend it.
100
+ // MCP Apps views (ChatGPT & Claude) render the interactive HTML card, which
101
+ // already embeds all thumbnails/videos. Claude's Apps bridge rejects a tool
102
+ // result that mixes raw `image` blocks with an app view ("could not be
103
+ // processed: Error processing image" + blank iframe), so we intentionally
104
+ // drop the standalone base64 image blocks here and keep only the text block
105
+ // carrying the HTML card + structured JSON.
121
106
  const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
122
107
  const rawText = textBlock ? String(textBlock.text ?? "") : "";
123
108
  const htmlPrefix = rawText.startsWith("<")
@@ -131,7 +116,7 @@ async function toToolResult(proxy) {
131
116
  const proxiedHtml = htmlPrefix ? proxyImageUrlsInHtml(htmlPrefix) : "";
132
117
  const text = proxiedHtml ? `${proxiedHtml}\n\n${textJson}` : textJson;
133
118
  return {
134
- content: [...images, { type: "text", text }],
119
+ content: [{ type: "text", text }],
135
120
  structuredContent: proxied ?? {},
136
121
  };
137
122
  }
@@ -259,49 +244,68 @@ export function createMcpServer(makeClient) {
259
244
  },
260
245
  },
261
246
  });
262
- // Register the single UI resource that all tools share.
263
- // The host reads this resource, renders it in a sandboxed iframe,
264
- // and pushes tool results via ui/notifications/tool-result.
265
- server.registerResource("Orchyn Interactive View", UI_RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => {
266
- // Build CSP resourceDomains from env so proxied thumbnails work
267
- const domains = [
268
- "https://*.tiktokcdn.com",
269
- "https://*.cdninstagram.com",
270
- "https://*.ytimg.com",
271
- "https://*.googlevideo.com",
272
- "https://*.licdn.com",
273
- "https://*.linkedin.com",
274
- ];
275
- const apiUrl = process.env.ORCHYN_API_URL || process.env.ORCHYN_BASE_URL;
276
- if (apiUrl && apiUrl.trim()) {
277
- domains.push(apiUrl.trim().replace(/\/+$/, ""));
278
- }
279
- const domain = await computeAppDomain();
280
- return {
281
- contents: [
282
- {
283
- uri: UI_RESOURCE_URI,
284
- mimeType: RESOURCE_MIME_TYPE,
285
- text: ORCHYN_UI_TEMPLATE,
286
- _meta: {
287
- ui: {
288
- ...(domain ? { domain } : {}),
289
- csp: {
290
- resourceDomains: domains,
247
+ // Register one UI app resource per tool/view. Claude/ChatGPT render a
248
+ // separate sandboxed app per resourceUri and key app state by it, so a
249
+ // distinct URI per tool avoids a shared app instance/session colliding
250
+ // between different tools (ext-apps#558). Each URI serves the same generic
251
+ // template, which renders whichever structuredResult the tool delivers.
252
+ const TOOL_URIS = [
253
+ "analyze_post",
254
+ "get_social_media",
255
+ "discover_social_posts",
256
+ "get_user_posts",
257
+ "analyze_creator_profile",
258
+ "get_post_comments",
259
+ "search_creators",
260
+ "get_similar_creators",
261
+ "discover_sounds",
262
+ "check_orchyn_credits",
263
+ "buy_orchyn_credits",
264
+ "understand_social_post",
265
+ ].map(uiResource);
266
+ // Build CSP resourceDomains from env so proxied thumbnails work
267
+ const domains = [
268
+ "https://*.tiktokcdn.com",
269
+ "https://*.cdninstagram.com",
270
+ "https://*.ytimg.com",
271
+ "https://*.googlevideo.com",
272
+ "https://*.licdn.com",
273
+ "https://*.linkedin.com",
274
+ ];
275
+ const apiUrl = process.env.ORCHYN_API_URL || process.env.ORCHYN_BASE_URL;
276
+ if (apiUrl && apiUrl.trim()) {
277
+ domains.push(apiUrl.trim().replace(/\/+$/, ""));
278
+ }
279
+ for (const uri of TOOL_URIS) {
280
+ server.registerResource("Orchyn Interactive View", uri, { mimeType: RESOURCE_MIME_TYPE }, async () => {
281
+ // Computed per-request so the claude.ai sandbox origin is correct.
282
+ const domain = await computeAppDomain();
283
+ return {
284
+ contents: [
285
+ {
286
+ uri,
287
+ mimeType: RESOURCE_MIME_TYPE,
288
+ text: ORCHYN_UI_TEMPLATE,
289
+ _meta: {
290
+ ui: {
291
+ ...(domain ? { domain } : {}),
292
+ csp: {
293
+ resourceDomains: domains,
294
+ },
295
+ prefersBorder: false,
291
296
  },
292
- prefersBorder: false,
293
297
  },
294
298
  },
295
- },
296
- ],
297
- };
298
- });
299
+ ],
300
+ };
301
+ });
302
+ }
299
303
  server.registerTool("analyze_post", {
300
304
  title: "Analyze Post",
301
305
  description: "Analyze a social post (video, image, carousel/slideshow) from its link — " +
302
306
  "imports the media and runs AI analysis over the actual content (video frames, carousel images, caption). " +
303
307
  "Supports TikTok, Instagram, YouTube, X/Twitter, Douyin, Xiaohongshu and Bilibili. Returns the full analysis once finished. First use free per user.",
304
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
308
+ _meta: { ui: { resourceUri: uiResource("analyze_post") }, "ui/resourceUri": uiResource("analyze_post") },
305
309
  inputSchema: z
306
310
  .object({
307
311
  url: z.string().describe("Public post URL (TikTok/Instagram/YouTube/X, Douyin, Xiaohongshu or Bilibili)."),
@@ -330,19 +334,14 @@ export function createMcpServer(makeClient) {
330
334
  const creatorHandle = String(post.creatorHandle ?? "");
331
335
  const title = String(post.title ?? post.caption ?? "");
332
336
  const htmlCard = buildAnalysisHtmlCard(proxied, thumbnailUrl, platform, creatorHandle, title);
333
- // The backend attaches `_inlineImages` as permanent orchyn public URLs
334
- // (re-hosted into storage, HEIC already transcoded to JPEG). The SDK's
335
- // ImageContent only accepts base64 `data`, so fetch each URL and encode it.
336
- // Skip image blocks whose base64 is empty (fetch failed) — clients
337
- // reject them with "could not be processed: Error processing image".
338
- const images = (await Promise.all((result.inlineImages ?? [])
339
- .filter((img) => typeof img === "object" && img !== null)
340
- .map((img) => "url" in img && img.url
341
- ? fetchAsBase64Image(String(img.url), String(img.mimeType ?? "image/jpeg"))
342
- : Promise.resolve({ type: "image", data: String(img.data ?? ""), mimeType: String(img.mimeType ?? "image/jpeg") })))).filter((img) => img.type === "image" && img.data.length > 0);
337
+ // The interactive HTML card above already embeds the thumbnail + full
338
+ // video, so no standalone base64 `image` blocks are emitted. Claude's Apps
339
+ // bridge rejects raw image blocks mixed with an app view ("could not be
340
+ // processed: Error processing image" + blank iframe), so we keep only the
341
+ // text block carrying the card + structured JSON.
343
342
  const textJson = JSON.stringify(proxied, null, 2);
344
343
  return {
345
- content: [...images, { type: "text", text: `${htmlCard}\n\n${textJson}` }],
344
+ content: [{ type: "text", text: `${htmlCard}\n\n${textJson}` }],
346
345
  structuredContent: proxied,
347
346
  };
348
347
  }
@@ -362,7 +361,7 @@ export function createMcpServer(makeClient) {
362
361
  description: "Fetch a social post's media from a TikTok, Instagram, YouTube, X/Twitter, Douyin, Xiaohongshu or Bilibili URL: " +
363
362
  "contentType (video/image/carousel/slideshow), title, caption, author, stats and direct media URLs. " +
364
363
  "Returns an inline thumbnail image. Consumes 1 orchyn credit. First use free per user.",
365
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
364
+ _meta: { ui: { resourceUri: uiResource("get_social_media") }, "ui/resourceUri": uiResource("get_social_media") },
366
365
  inputSchema: z
367
366
  .object({
368
367
  url: z.string().describe("Full public post URL."),
@@ -382,7 +381,7 @@ export function createMcpServer(makeClient) {
382
381
  description: "Discover recent posts (video, image, carousel, slideshow) for a niche on YouTube, TikTok, Instagram, Douyin, Xiaohongshu, X/Twitter or Bilibili. " +
383
382
  "Each post includes title/caption, thumbnailUrl, externalUrl, views/likes/comments and inline thumbnails (up to 4) so they show in chat. " +
384
383
  'Say "next" to paginate (offset), or "analyze the 2nd one" / "analyze all" for batch analysis. Consumes 2 orchyn credits. First use free per user.',
385
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
384
+ _meta: { ui: { resourceUri: uiResource("discover_social_posts") }, "ui/resourceUri": uiResource("discover_social_posts") },
386
385
  inputSchema: z
387
386
  .object({
388
387
  niche: z.string().describe("Niche/topic, e.g. 'fitness'."),
@@ -409,7 +408,7 @@ export function createMcpServer(makeClient) {
409
408
  description: "List recent posts by a creator handle (e.g. @zoundsapp) on TikTok, Instagram, YouTube, Douyin, Xiaohongshu, X/Twitter, Bilibili or LinkedIn (LinkedIn uses the profile public_id, e.g. 'billgates'). " +
410
409
  "Each post includes title/caption, thumbnailUrl, externalUrl, views/likes/comments and inline thumbnails (up to 4) so they show in chat. " +
411
410
  "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. First use free per user.",
412
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
411
+ _meta: { ui: { resourceUri: uiResource("get_user_posts") }, "ui/resourceUri": uiResource("get_user_posts") },
413
412
  inputSchema: z
414
413
  .object({
415
414
  username: z.string().describe("Creator handle, e.g. 'zoundsapp' or '@zoundsapp'."),
@@ -434,7 +433,7 @@ export function createMcpServer(makeClient) {
434
433
  description: "Deep-dive a whole creator profile on TikTok, Instagram, YouTube, Douyin, Xiaohongshu, X/Twitter, Bilibili or LinkedIn: fetch recent posts, run multimodal AI on up to 3, " +
435
434
  "then synthesize a profile report — creator summary, niche, content themes, hook styles, strengths/weaknesses, " +
436
435
  "engagement patterns, audience insights, variation ideas, collaboration fit. Consumes 15 orchyn credits. First use free per user.",
437
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
436
+ _meta: { ui: { resourceUri: uiResource("analyze_creator_profile") }, "ui/resourceUri": uiResource("analyze_creator_profile") },
438
437
  inputSchema: z
439
438
  .object({
440
439
  username: z.string().describe("Creator handle, e.g. 'zoundsapp'."),
@@ -459,7 +458,7 @@ export function createMcpServer(makeClient) {
459
458
  title: "Get Post Comments",
460
459
  description: "Fetch top comments for a post URL on TikTok, Instagram, YouTube, Douyin, X/Twitter, Bilibili or LinkedIn, plus keyword clusters from TikTok Analytics " +
461
460
  "when available — audience sentiment/audience-signal analysis. Consumes 2 orchyn credits. First use free per user.",
462
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
461
+ _meta: { ui: { resourceUri: uiResource("get_post_comments") }, "ui/resourceUri": uiResource("get_post_comments") },
463
462
  inputSchema: z
464
463
  .object({
465
464
  url: z.string().describe("Full public post URL (TikTok/Instagram/YouTube/Douyin/X/Bilibili/LinkedIn)."),
@@ -479,7 +478,7 @@ export function createMcpServer(makeClient) {
479
478
  title: "Search Creators",
480
479
  description: "Search creators by niche/keyword on TikTok, Instagram, Xiaohongshu, YouTube or Douyin — username, nickname, follower count, " +
481
480
  "signature, verified status. Use to find influencers to vet or analyze. Consumes 2 orchyn credits. First use free per user.",
482
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
481
+ _meta: { ui: { resourceUri: uiResource("search_creators") }, "ui/resourceUri": uiResource("search_creators") },
483
482
  inputSchema: z
484
483
  .object({
485
484
  keyword: z.string().describe("Niche/keyword, e.g. 'fitness' or a creator name."),
@@ -503,7 +502,7 @@ export function createMcpServer(makeClient) {
503
502
  title: "Get Similar Creators",
504
503
  description: "Find lookalike creators for a given handle — TikTok similar-user recommendations or Instagram " +
505
504
  "similar users. Useful for scaling: 'if this creator works, here are more like them'. Consumes 2 orchyn credits. First use free per user.",
506
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
505
+ _meta: { ui: { resourceUri: uiResource("get_similar_creators") }, "ui/resourceUri": uiResource("get_similar_creators") },
507
506
  inputSchema: z
508
507
  .object({
509
508
  username: z.string().describe("Seed creator handle, e.g. 'zoundsapp'."),
@@ -523,7 +522,7 @@ export function createMcpServer(makeClient) {
523
522
  title: "Discover Sounds",
524
523
  description: "Discover trending sounds/music for a keyword on TikTok or Instagram — the sound is a huge ranking " +
525
524
  "signal for TikTok virality. Returns title, artist, duration, play/cover URLs. Consumes 2 orchyn credits. First use free per user.",
526
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
525
+ _meta: { ui: { resourceUri: uiResource("discover_sounds") }, "ui/resourceUri": uiResource("discover_sounds") },
527
526
  inputSchema: z
528
527
  .object({
529
528
  keyword: z.string().describe("Niche/keyword, e.g. 'gym'."),
@@ -543,7 +542,7 @@ export function createMcpServer(makeClient) {
543
542
  server.registerTool("check_orchyn_credits", {
544
543
  title: "Check Orchyn Credits",
545
544
  description: "Check your orchyn credit balance, billing URL and pack size. No cost — call anytime to see remaining credits before running other tools.",
546
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
545
+ _meta: { ui: { resourceUri: uiResource("check_orchyn_credits") }, "ui/resourceUri": uiResource("check_orchyn_credits") },
547
546
  inputSchema: z.object({}).strict(),
548
547
  }, async (_args, extra) => {
549
548
  const client = await makeClient(extra);
@@ -557,7 +556,7 @@ export function createMcpServer(makeClient) {
557
556
  server.registerTool("buy_orchyn_credits", {
558
557
  title: "Buy Orchyn Credits",
559
558
  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.",
560
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
559
+ _meta: { ui: { resourceUri: uiResource("buy_orchyn_credits") }, "ui/resourceUri": uiResource("buy_orchyn_credits") },
561
560
  inputSchema: z.object({}).strict(),
562
561
  }, async (_args, extra) => {
563
562
  const client = await makeClient(extra);
@@ -573,7 +572,7 @@ export function createMcpServer(makeClient) {
573
572
  description: "Import a social post URL AND understand it with multimodal AI over the actual video/images: " +
574
573
  "summary, hook strength, viral triggers, format breakdown and variation ideas. Includes the thumbnail. " +
575
574
  "Supports TikTok, Instagram, YouTube, X/Twitter, Douyin, Xiaohongshu and Bilibili. Consumes 6 orchyn credits. First use free per user.",
576
- _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
575
+ _meta: { ui: { resourceUri: uiResource("understand_social_post") }, "ui/resourceUri": uiResource("understand_social_post") },
577
576
  inputSchema: z
578
577
  .object({
579
578
  url: z.string().describe("Full public post URL (TikTok/Instagram/YouTube/X/Douyin/Xiaohongshu/Bilibili)."),
@@ -526,10 +526,41 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
526
526
  }
527
527
 
528
528
  // ─── Render ───
529
+ // Hosts deliver the tool result in two shapes:
530
+ // - { structuredContent: {...} } (spec-optional, used when present)
531
+ // - { content: [ {type,text,...} ] } (standard MCP content blocks)
532
+ // Our results put the HTML card prefix + structured JSON inside the first
533
+ // text content block, so decode BOTH. If the result is a primitive/string,
534
+ // surface it as-is.
535
+ function extractResult(result){
536
+ if(!result||typeof result!=="object")return result;
537
+ var sc=result.structuredContent;
538
+ if(sc&&typeof sc==="object"&&!Array.isArray(sc))return sc;
539
+ if(Array.isArray(result.content)){
540
+ var textBlock=null;
541
+ for(var i=0;i<result.content.length;i++){
542
+ var c=result.content[i];
543
+ if(c&&c.type==="text"&&typeof c.text==="string"){textBlock=c.text;break;}
544
+ }
545
+ if(typeof textBlock==="string"&&textBlock!==""){
546
+ // Split "<html>\n\n{json}" — if there's a JSON object after the HTML,
547
+ // parse it; otherwise show the text itself.
548
+ var m=/^[\s]*<[^>]+>[\s\S]*?\n\n(\{.*\})[\s]*$/m.exec(textBlock);
549
+ if(m){try{var parsed=JSON.parse(m[1]);if(parsed&&typeof parsed==="object")return parsed;}catch(e){}}
550
+ // Single-line HTML (common): keep the whole block as text fallback.
551
+ if(textBlock.indexOf("<")===0){return { _html: textBlock };}
552
+ try{var j=JSON.parse(textBlock);if(j&&typeof j==="object")return j;}catch(e2){}
553
+ }
554
+ }
555
+ return result;
556
+ }
557
+
529
558
  function render(result){
530
559
  var app=document.getElementById("app");if(!app)return;
531
- var d=result&&result.structuredContent?result.structuredContent:result;
560
+ var d=extractResult(result);
532
561
  if(!d||typeof d!=="object"){app.innerHTML='<div class="json-block fade-in">'+esc(JSON.stringify(d,null,2))+"</div>";return;}
562
+ // Raw HTML-only result — inject it directly.
563
+ if(d._html){app.innerHTML=d._html;setTimeout(reportSize,50);return;}
533
564
 
534
565
  // Analysis
535
566
  if(d.analysis&&(d.post||d.url)){app.innerHTML=analysisCard(d);return;}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchyn/mcp",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "description": "MCP server for orchyn - fetch, discover and understand TikTok/Instagram/YouTube/X/LinkedIn posts with AI (media metadata, niche discovery, hook & viral analysis)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",