@orchyn/mcp 1.13.0 → 1.15.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,29 +11,34 @@ 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.13.0";
14
+ export const MCP_SERVER_VERSION = "1.15.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
19
  /** Single UI resource URI shared by all tools */
20
20
  const UI_RESOURCE_URI = "ui://orchyn/view";
21
- /** Convert an arbitrary http(s) URL to a base64 MCP `image` data block. */
22
- async function fetchAsBase64Image(url, mimeType) {
21
+ /**
22
+ * claude.ai requires `ui.domain` on the resource == sha256("<MCP endpoint
23
+ * URL>")[:32] + ".claudemcpcontent.com" — the iframe is only revealed on
24
+ * that dedicated sandbox origin. The endpoint is the worker's public origin
25
+ * + "/mcp". stdio runs have no public URL, so the field is omitted and the
26
+ * host falls back to its default per-conversation origin.
27
+ */
28
+ async function computeAppDomain() {
29
+ const publicUrl = (process.env.PUBLIC_URL || "").trim().replace(/\/+$/, "");
30
+ if (!publicUrl)
31
+ return undefined;
23
32
  try {
24
- const res = await fetch(url, { signal: AbortSignal.timeout(12_000) });
25
- if (!res.ok)
26
- return { type: "image", data: "", mimeType };
27
- const buf = await res.arrayBuffer();
28
- const bytes = new Uint8Array(buf);
29
- let bin = "";
30
- for (let i = 0; i < bytes.length; i += 0x8000) {
31
- bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
32
- }
33
- return { type: "image", data: btoa(bin), mimeType };
33
+ const endpoint = `${publicUrl}/mcp`;
34
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(endpoint));
35
+ const hex = Array.from(new Uint8Array(digest))
36
+ .map((b) => b.toString(16).padStart(2, "0"))
37
+ .join("");
38
+ return `${hex.slice(0, 32)}.claudemcpcontent.com`;
34
39
  }
35
40
  catch {
36
- return { type: "image", data: "", mimeType };
41
+ return undefined;
37
42
  }
38
43
  }
39
44
  /**
@@ -82,17 +87,12 @@ function proxyUrls(obj) {
82
87
  return obj;
83
88
  }
84
89
  async function toToolResult(proxy) {
85
- // The MCP SDK's ImageContent only accepts `data` (base64). The orchyn
86
- // backend now emits `url` image blocks pointing at permanent, re-hosted
87
- // JPEG thumbnails (HEIC already transcoded), so convert those URLs to
88
- // base64 data here guaranteeing clients render a valid image.
89
- const imgBlocks = proxy.contentBlocks.filter((c) => c.type === "image");
90
- const images = await Promise.all(imgBlocks.map((c) => c.url
91
- ? fetchAsBase64Image(String(c.url), String(c.mimeType ?? "image/jpeg"))
92
- : Promise.resolve({ type: "image", data: String(c.data ?? ""), mimeType: String(c.mimeType ?? "image/jpeg") })));
93
- // The Rust backend embeds HTML cards directly in the text block
94
- // (type:"text", text:"<div>...</div>\n\n{json}"). We extract the HTML
95
- // prefix from the first text contentBlock and prepend it.
90
+ // MCP Apps views (ChatGPT & Claude) render the interactive HTML card, which
91
+ // already embeds all thumbnails/videos. Claude's Apps bridge rejects a tool
92
+ // result that mixes raw `image` blocks with an app view ("could not be
93
+ // processed: Error processing image" + blank iframe), so we intentionally
94
+ // drop the standalone base64 image blocks here and keep only the text block
95
+ // carrying the HTML card + structured JSON.
96
96
  const textBlock = proxy.contentBlocks.find((c) => c.type === "text");
97
97
  const rawText = textBlock ? String(textBlock.text ?? "") : "";
98
98
  const htmlPrefix = rawText.startsWith("<")
@@ -106,7 +106,7 @@ async function toToolResult(proxy) {
106
106
  const proxiedHtml = htmlPrefix ? proxyImageUrlsInHtml(htmlPrefix) : "";
107
107
  const text = proxiedHtml ? `${proxiedHtml}\n\n${textJson}` : textJson;
108
108
  return {
109
- content: [...images, { type: "text", text }],
109
+ content: [{ type: "text", text }],
110
110
  structuredContent: proxied ?? {},
111
111
  };
112
112
  }
@@ -251,6 +251,7 @@ export function createMcpServer(makeClient) {
251
251
  if (apiUrl && apiUrl.trim()) {
252
252
  domains.push(apiUrl.trim().replace(/\/+$/, ""));
253
253
  }
254
+ const domain = await computeAppDomain();
254
255
  return {
255
256
  contents: [
256
257
  {
@@ -259,9 +260,11 @@ export function createMcpServer(makeClient) {
259
260
  text: ORCHYN_UI_TEMPLATE,
260
261
  _meta: {
261
262
  ui: {
263
+ ...(domain ? { domain } : {}),
262
264
  csp: {
263
265
  resourceDomains: domains,
264
266
  },
267
+ prefersBorder: false,
265
268
  },
266
269
  },
267
270
  },
@@ -273,7 +276,7 @@ export function createMcpServer(makeClient) {
273
276
  description: "Analyze a social post (video, image, carousel/slideshow) from its link — " +
274
277
  "imports the media and runs AI analysis over the actual content (video frames, carousel images, caption). " +
275
278
  "Supports TikTok, Instagram, YouTube, X/Twitter, Douyin, Xiaohongshu and Bilibili. Returns the full analysis once finished. First use free per user.",
276
- _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
279
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
277
280
  inputSchema: z
278
281
  .object({
279
282
  url: z.string().describe("Public post URL (TikTok/Instagram/YouTube/X, Douyin, Xiaohongshu or Bilibili)."),
@@ -302,17 +305,14 @@ export function createMcpServer(makeClient) {
302
305
  const creatorHandle = String(post.creatorHandle ?? "");
303
306
  const title = String(post.title ?? post.caption ?? "");
304
307
  const htmlCard = buildAnalysisHtmlCard(proxied, thumbnailUrl, platform, creatorHandle, title);
305
- // The backend attaches `_inlineImages` as permanent orchyn public URLs
306
- // (re-hosted into storage, HEIC already transcoded to JPEG). The SDK's
307
- // ImageContent only accepts base64 `data`, so fetch each URL and encode it.
308
- const images = await Promise.all((result.inlineImages ?? [])
309
- .filter((img) => typeof img === "object" && img !== null)
310
- .map((img) => "url" in img && img.url
311
- ? fetchAsBase64Image(String(img.url), String(img.mimeType ?? "image/jpeg"))
312
- : Promise.resolve({ type: "image", data: String(img.data ?? ""), mimeType: String(img.mimeType ?? "image/jpeg") })));
308
+ // The interactive HTML card above already embeds the thumbnail + full
309
+ // video, so no standalone base64 `image` blocks are emitted. Claude's Apps
310
+ // bridge rejects raw image blocks mixed with an app view ("could not be
311
+ // processed: Error processing image" + blank iframe), so we keep only the
312
+ // text block carrying the card + structured JSON.
313
313
  const textJson = JSON.stringify(proxied, null, 2);
314
314
  return {
315
- content: [...images, { type: "text", text: `${htmlCard}\n\n${textJson}` }],
315
+ content: [{ type: "text", text: `${htmlCard}\n\n${textJson}` }],
316
316
  structuredContent: proxied,
317
317
  };
318
318
  }
@@ -332,7 +332,7 @@ export function createMcpServer(makeClient) {
332
332
  description: "Fetch a social post's media from a TikTok, Instagram, YouTube, X/Twitter, Douyin, Xiaohongshu or Bilibili URL: " +
333
333
  "contentType (video/image/carousel/slideshow), title, caption, author, stats and direct media URLs. " +
334
334
  "Returns an inline thumbnail image. Consumes 1 orchyn credit. First use free per user.",
335
- _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
335
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
336
336
  inputSchema: z
337
337
  .object({
338
338
  url: z.string().describe("Full public post URL."),
@@ -352,7 +352,7 @@ export function createMcpServer(makeClient) {
352
352
  description: "Discover recent posts (video, image, carousel, slideshow) for a niche on YouTube, TikTok, Instagram, Douyin, Xiaohongshu, X/Twitter or Bilibili. " +
353
353
  "Each post includes title/caption, thumbnailUrl, externalUrl, views/likes/comments and inline thumbnails (up to 4) so they show in chat. " +
354
354
  'Say "next" to paginate (offset), or "analyze the 2nd one" / "analyze all" for batch analysis. Consumes 2 orchyn credits. First use free per user.',
355
- _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
355
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
356
356
  inputSchema: z
357
357
  .object({
358
358
  niche: z.string().describe("Niche/topic, e.g. 'fitness'."),
@@ -379,7 +379,7 @@ export function createMcpServer(makeClient) {
379
379
  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'). " +
380
380
  "Each post includes title/caption, thumbnailUrl, externalUrl, views/likes/comments and inline thumbnails (up to 4) so they show in chat. " +
381
381
  "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.",
382
- _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
382
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
383
383
  inputSchema: z
384
384
  .object({
385
385
  username: z.string().describe("Creator handle, e.g. 'zoundsapp' or '@zoundsapp'."),
@@ -404,7 +404,7 @@ export function createMcpServer(makeClient) {
404
404
  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, " +
405
405
  "then synthesize a profile report — creator summary, niche, content themes, hook styles, strengths/weaknesses, " +
406
406
  "engagement patterns, audience insights, variation ideas, collaboration fit. Consumes 15 orchyn credits. First use free per user.",
407
- _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
407
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
408
408
  inputSchema: z
409
409
  .object({
410
410
  username: z.string().describe("Creator handle, e.g. 'zoundsapp'."),
@@ -429,7 +429,7 @@ export function createMcpServer(makeClient) {
429
429
  title: "Get Post Comments",
430
430
  description: "Fetch top comments for a post URL on TikTok, Instagram, YouTube, Douyin, X/Twitter, Bilibili or LinkedIn, plus keyword clusters from TikTok Analytics " +
431
431
  "when available — audience sentiment/audience-signal analysis. Consumes 2 orchyn credits. First use free per user.",
432
- _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
432
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
433
433
  inputSchema: z
434
434
  .object({
435
435
  url: z.string().describe("Full public post URL (TikTok/Instagram/YouTube/Douyin/X/Bilibili/LinkedIn)."),
@@ -449,7 +449,7 @@ export function createMcpServer(makeClient) {
449
449
  title: "Search Creators",
450
450
  description: "Search creators by niche/keyword on TikTok, Instagram, Xiaohongshu, YouTube or Douyin — username, nickname, follower count, " +
451
451
  "signature, verified status. Use to find influencers to vet or analyze. Consumes 2 orchyn credits. First use free per user.",
452
- _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
452
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
453
453
  inputSchema: z
454
454
  .object({
455
455
  keyword: z.string().describe("Niche/keyword, e.g. 'fitness' or a creator name."),
@@ -473,7 +473,7 @@ export function createMcpServer(makeClient) {
473
473
  title: "Get Similar Creators",
474
474
  description: "Find lookalike creators for a given handle — TikTok similar-user recommendations or Instagram " +
475
475
  "similar users. Useful for scaling: 'if this creator works, here are more like them'. Consumes 2 orchyn credits. First use free per user.",
476
- _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
476
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
477
477
  inputSchema: z
478
478
  .object({
479
479
  username: z.string().describe("Seed creator handle, e.g. 'zoundsapp'."),
@@ -493,7 +493,7 @@ export function createMcpServer(makeClient) {
493
493
  title: "Discover Sounds",
494
494
  description: "Discover trending sounds/music for a keyword on TikTok or Instagram — the sound is a huge ranking " +
495
495
  "signal for TikTok virality. Returns title, artist, duration, play/cover URLs. Consumes 2 orchyn credits. First use free per user.",
496
- _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
496
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
497
497
  inputSchema: z
498
498
  .object({
499
499
  keyword: z.string().describe("Niche/keyword, e.g. 'gym'."),
@@ -513,7 +513,7 @@ export function createMcpServer(makeClient) {
513
513
  server.registerTool("check_orchyn_credits", {
514
514
  title: "Check Orchyn Credits",
515
515
  description: "Check your orchyn credit balance, billing URL and pack size. No cost — call anytime to see remaining credits before running other tools.",
516
- _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
516
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
517
517
  inputSchema: z.object({}).strict(),
518
518
  }, async (_args, extra) => {
519
519
  const client = await makeClient(extra);
@@ -527,7 +527,7 @@ export function createMcpServer(makeClient) {
527
527
  server.registerTool("buy_orchyn_credits", {
528
528
  title: "Buy Orchyn Credits",
529
529
  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.",
530
- _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
530
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
531
531
  inputSchema: z.object({}).strict(),
532
532
  }, async (_args, extra) => {
533
533
  const client = await makeClient(extra);
@@ -543,7 +543,7 @@ export function createMcpServer(makeClient) {
543
543
  description: "Import a social post URL AND understand it with multimodal AI over the actual video/images: " +
544
544
  "summary, hook strength, viral triggers, format breakdown and variation ideas. Includes the thumbnail. " +
545
545
  "Supports TikTok, Instagram, YouTube, X/Twitter, Douyin, Xiaohongshu and Bilibili. Consumes 6 orchyn credits. First use free per user.",
546
- _meta: { ui: { resourceUri: UI_RESOURCE_URI } },
546
+ _meta: { ui: { resourceUri: UI_RESOURCE_URI }, "ui/resourceUri": UI_RESOURCE_URI },
547
547
  inputSchema: z
548
548
  .object({
549
549
  url: z.string().describe("Full public post URL (TikTok/Instagram/YouTube/X/Douyin/Xiaohongshu/Bilibili)."),
@@ -214,6 +214,7 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
214
214
  clearTimeout(loadingTimer);
215
215
  clearTimeout(fallbackTimer);
216
216
  render(d.params);
217
+ setTimeout(reportSize,50);
217
218
  }
218
219
  if(d.method==="ui/notifications/tool-input-partial"){
219
220
  currentTool=d.params&&d.params.name?d.params.name:"";
@@ -269,14 +270,40 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
269
270
  +'</div></div>';
270
271
  },3000);
271
272
 
272
- // MCP Apps handshake
273
+ // MCP Apps handshake.
274
+ // Claude's McpUiInitializeRequestSchema requires appInfo, appCapabilities
275
+ // and protocolVersion (legacy hosts read capabilities/clientInfo), so send
276
+ // both shapes. Critically, ui/notifications/initialized must be sent
277
+ // UNCONDITIONALLY — Claude keeps the widget iframe reserved-but-hidden
278
+ // until it receives that notification, so a reply we don't recognize must
279
+ // never deadlock the handshake.
280
+ var initializedSent=false;
281
+ function sendInitialized(){
282
+ if(initializedSent)return;
283
+ initializedSent=true;
284
+ window.parent.postMessage({jsonrpc:"2.0",method:"ui/notifications/initialized",params:{}},"*");
285
+ }
273
286
  send("ui/initialize",{
274
287
  protocolVersion:"2026-01-26",
288
+ appInfo:{name:"orchyn-view",version:"2.0.0"},
289
+ appCapabilities:{availableDisplayModes:["inline"]},
275
290
  capabilities:{},
276
291
  clientInfo:{name:"orchyn-view",version:"2.0.0"}
277
- }).then(function(){
278
- window.parent.postMessage({jsonrpc:"2.0",method:"ui/notifications/initialized",params:{}},"*");
279
- }).catch(function(){});
292
+ }).then(sendInitialized).catch(sendInitialized);
293
+ // Unconditional fallback: never wait on a reply shape we don't recognize.
294
+ setTimeout(sendInitialized, 500);
295
+
296
+ // Report content size so flexible hosts (Claude) size the iframe correctly.
297
+ // Params MUST be real numbers — claude.ai throws on null/missing width.
298
+ function reportSize(){
299
+ try{
300
+ var h=document.documentElement.scrollHeight||document.body.scrollHeight||400;
301
+ var w=document.documentElement.scrollWidth||document.body.scrollWidth||400;
302
+ window.parent.postMessage({jsonrpc:"2.0",method:"ui/notifications/size-changed",params:{height:h,width:w}},"*");
303
+ }catch(e){}
304
+ }
305
+ window.addEventListener("resize",reportSize);
306
+ setTimeout(reportSize,600);
280
307
 
281
308
  // ─── Helpers ───
282
309
  function esc(s){return String(s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");}
@@ -563,6 +590,7 @@ body{font-family:system-ui,-apple-system,'Segoe UI',sans-serif;background:var(--
563
590
  if(d.post||d.platform){app.innerHTML=postCard(d.post||d,true);return;}
564
591
  // Fallback
565
592
  app.innerHTML='<div class="json-block fade-in">'+esc(JSON.stringify(d,null,2))+"</div>";
593
+ setTimeout(reportSize,50);
566
594
  }
567
595
 
568
596
  function updateProgress(params){
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orchyn/mcp",
3
- "version": "1.13.0",
3
+ "version": "1.15.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",