@cloudinary/asset-management-mcp 0.9.0 → 0.9.2

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 (64) hide show
  1. package/README.md +12 -15
  2. package/bin/mcp-server.js +269 -379
  3. package/bin/mcp-server.js.map +17 -17
  4. package/esm/funcs/searchSearchAssets.d.ts +46 -2
  5. package/esm/funcs/searchSearchAssets.d.ts.map +1 -1
  6. package/esm/funcs/searchSearchAssets.js +46 -2
  7. package/esm/funcs/searchSearchAssets.js.map +1 -1
  8. package/esm/landing-page.d.ts.map +1 -1
  9. package/esm/landing-page.js +9 -3
  10. package/esm/landing-page.js.map +1 -1
  11. package/esm/lib/config.d.ts +4 -4
  12. package/esm/lib/config.js +4 -4
  13. package/esm/mcp-server/apps/app-shared.d.ts +5 -4
  14. package/esm/mcp-server/apps/app-shared.d.ts.map +1 -1
  15. package/esm/mcp-server/apps/app-shared.js +79 -12
  16. package/esm/mcp-server/apps/app-shared.js.map +1 -1
  17. package/esm/mcp-server/apps/asset-details-app.d.ts.map +1 -1
  18. package/esm/mcp-server/apps/asset-details-app.js +7 -14
  19. package/esm/mcp-server/apps/asset-details-app.js.map +1 -1
  20. package/esm/mcp-server/apps/asset-gallery-app.d.ts.map +1 -1
  21. package/esm/mcp-server/apps/asset-gallery-app.js +75 -298
  22. package/esm/mcp-server/apps/asset-gallery-app.js.map +1 -1
  23. package/esm/mcp-server/apps/asset-upload-app.d.ts.map +1 -1
  24. package/esm/mcp-server/apps/asset-upload-app.js +29 -24
  25. package/esm/mcp-server/apps/asset-upload-app.js.map +1 -1
  26. package/esm/mcp-server/apps/config.d.ts.map +1 -1
  27. package/esm/mcp-server/apps/config.js +1 -2
  28. package/esm/mcp-server/apps/config.js.map +1 -1
  29. package/esm/mcp-server/apps/extensions.d.ts.map +1 -1
  30. package/esm/mcp-server/apps/extensions.js +6 -1
  31. package/esm/mcp-server/apps/extensions.js.map +1 -1
  32. package/esm/mcp-server/cli/serve/impl.js +1 -1
  33. package/esm/mcp-server/cli/serve/impl.js.map +1 -1
  34. package/esm/mcp-server/mcp-server.js +1 -1
  35. package/esm/mcp-server/server.js +1 -1
  36. package/esm/mcp-server/tools/searchSearchAssets.d.ts.map +1 -1
  37. package/esm/mcp-server/tools/searchSearchAssets.js +46 -2
  38. package/esm/mcp-server/tools/searchSearchAssets.js.map +1 -1
  39. package/esm/models/searchparameters.d.ts +4 -1
  40. package/esm/models/searchparameters.d.ts.map +1 -1
  41. package/esm/models/searchparameters.js +9 -8
  42. package/esm/models/searchparameters.js.map +1 -1
  43. package/esm/tool-names.js +1 -1
  44. package/esm/tool-names.js.map +1 -1
  45. package/esm/types/bigint.d.ts.map +1 -1
  46. package/esm/types/bigint.js +4 -3
  47. package/esm/types/bigint.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/funcs/searchSearchAssets.ts +46 -2
  50. package/src/landing-page.ts +9 -3
  51. package/src/lib/config.ts +4 -4
  52. package/src/mcp-server/apps/app-shared.ts +80 -12
  53. package/src/mcp-server/apps/asset-details-app.ts +7 -13
  54. package/src/mcp-server/apps/asset-gallery-app.ts +75 -297
  55. package/src/mcp-server/apps/asset-upload-app.ts +29 -23
  56. package/src/mcp-server/apps/config.ts +1 -2
  57. package/src/mcp-server/apps/extensions.ts +6 -1
  58. package/src/mcp-server/cli/serve/impl.ts +1 -1
  59. package/src/mcp-server/mcp-server.ts +1 -1
  60. package/src/mcp-server/server.ts +1 -1
  61. package/src/mcp-server/tools/searchSearchAssets.ts +46 -2
  62. package/src/models/searchparameters.ts +25 -9
  63. package/src/tool-names.ts +1 -1
  64. package/src/types/bigint.ts +18 -17
@@ -32,9 +32,53 @@ import { Result } from "../types/fp.js";
32
32
  * @remarks
33
33
  * Returns a list of resources matching the specified search criteria.
34
34
  *
35
- * Uses Lucene-like query language to search by descriptive attributes (public_id, filename, folder, tags, context), file details (resource_type, format, bytes, width, height), embedded data (image_metadata), and analyzed data (face_count, colors, quality_score). Supports aggregate counts and complex Boolean expressions.
35
+ * Uses a Lucene-like query language to filter assets by descriptive attributes (`public_id`, `asset_id`, `filename`, `display_name`, `folder` / `asset_folder`, `tags`, `context.<key>`), file details (`resource_type`, `type`, `format`, `bytes`, `width`, `height`, `duration`, `pages`, `aspect_ratio`, `transparent`, `grayscale`), lifecycle dates (`uploaded_at`, `created_at`, `taken_at`, `updated_at`, `last_updated.<kind>`), moderation and lifecycle state (`status`, `moderation_status`, `moderation_kind`), embedded data (`image_metadata.*`), structured metadata (`metadata.<external_id>`), and analysis fields (`face_count`, `colors`, `quality_score`, `illustration_score`, `accessibility_analysis.*`). Supports sorting, aggregate counts, and complex boolean expressions. See the `expression` parameter for the full field reference.
36
36
  *
37
- * Examples: tags:shirt AND uploaded_at>1d, resource_type:image AND bytes>1mb, folder:products OR context.category:electronics
37
+ * ## Expression syntax
38
+ *
39
+ * - **Match**: `field:value` (token match) or `field=value` (exact match). Examples: `tags:shirt`, `tags=cotton`.
40
+ * - **Comparisons**: `>`, `<`, `>=`, `<=` for numbers and dates. Example: `bytes>10000000`.
41
+ * - **Ranges**: `field:[from TO to]` inclusive, `field:{from TO to}` exclusive. Example: `width:{200 TO 1028}`.
42
+ * - **Booleans**: `AND`, `OR`, `NOT` (uppercase), or `+` (must), `-` (must not). `NOT` must appear between clauses — a bare leading `NOT` is a parse error; use `-field:value` to negate the first clause. Group with parentheses: `(shirt OR pants) AND clothes`.
43
+ * - **Wildcards**: trailing `*` only, for prefix match (`public_id:shoes_*`, `format:jp*`, `tags:shirt*`). Not supported on `folder`, `asset_folder`, `resource_type`, or `type`. Leading `*`, middle `*`, `?`, and bare `*` (`folder:*`, `context.alt:*`) are all parse errors — wildcards cannot be used as a "field is present" probe.
44
+ * - **Tokenized vs exact fields**: `tags`, `filename`, `display_name`, `context.<key>`, and `metadata.<id>` match on tokens split by whitespace and punctuation — `tags:analysis` matches the tag `full-analysis`. `public_id`, `folder`, `asset_folder`, and `format` match the whole value — `public_id:dog` will not match `dog_pldcwy`; use `public_id="dog_pldcwy"` (exact) or `public_id:dog*` (prefix). These exact-match fields still accept a trailing `*` for prefix match (except `folder` / `asset_folder`, where wildcards are ignored).
45
+ * - **Dates**: ISO-8601 in quotes (`uploaded_at>"2024-01-15"`) or relative shorthand `Nh`, `Nd`, `Nw`, `Nm`, `Ny` (`uploaded_at>1d`, `created_at:[4w TO 1w]`). Send raw `<`/`>`, never HTML-escaped.
46
+ * - **Quoting**: wrap any value containing a space, colon, or other reserved character (`! ( ) { } [ ] ^ ~ ? \ = & < > |`) in double quotes, or escape each character with `\`. Examples: `tags:"service:mantels"`, `aspect_ratio:"16:9"`, `folder:"My Folder"`.
47
+ *
48
+ * ## Common mistakes
49
+ *
50
+ * - Use `folder:` or `asset_folder:` (singular); `folders:`, `asset_folder_id:`, and other invented variants are not valid fields. Pass the exact folder name — wildcards do not apply here.
51
+ * - There is no "has any value" / presence probe. `folder:*`, `metadata.alt:*`, `context.key:*`, `tags:*`, and `-tags:*` are all parse errors. See *"Which assets have any value for `metadata.<id>`?"* under **Common tasks** for workarounds.
52
+ * - `NOT foo AND bar` is a parse error. Write it as `bar AND NOT foo` or `-foo AND bar`, and keep every `NOT` between two clauses (`a AND NOT b AND NOT c` is fine; `NOT b AND NOT c …` is not).
53
+ * - `public_id:dog` will not match `dog_pldcwy`. Use `public_id="dog_pldcwy"` (exact) or `public_id:dog*` (prefix).
54
+ * - `tags=service:mantels` fails because the unquoted colon is parsed as a field separator. Use `tags="service:mantels"` or `tags=service\:mantels`.
55
+ * - Do not HTML-escape operators. Send `uploaded_at<1h`, not `uploaded_at&lt;1h`.
56
+ * - Do not leave an operand empty (e.g. `tags: AND -tags:foo`). Omit the empty clause entirely.
57
+ *
58
+ * ## Tips
59
+ *
60
+ * - Set `max_results: 0` to return only `total_count` and `aggregations` without any resource payload — useful for counts and aggregation-only queries.
61
+ * - `total_count` is always present in the response; prefer it over running an aggregation just to get a count.
62
+ * - `aggregate` (both simple and range variants) and the `metadata`, `image_metadata`, `image_analysis` values of `with_field` require a Tier 2 search plan.
63
+ * - Range aggregations require each range to include a `key` label (1–20 chars, `[a-zA-Z0-9_-]+`) and at least one of `from` / `to`.
64
+ *
65
+ * ## Common tasks
66
+ *
67
+ * - **Count matching assets** — put the filter in `expression` with `max_results: 0` and read `total_count` from the response. Works on every tier; no `aggregate` needed.
68
+ * - **Preview one matching asset** — set `max_results: 1`; add `with_field: ["tags", "context"]` (or `metadata`, Tier 2) to inspect values. Prefer this over fetching and scanning a full page.
69
+ * - **Distribution of values for a field** — Tier 2: `aggregate: [format|resource_type|type]` for enum counts, or range aggregations on `bytes`, `image_pixels`, `video_pixels`, or `duration`. Tier 1 fallback: run N small queries with `max_results: 0`, one per candidate value, and read `total_count` from each.
70
+ * - **"Which assets have any value for `metadata.<id>`?"** — not expressible directly (`metadata.X:*` is a parse error; there is no presence probe). Workarounds: (a) if the field has a known value set, enumerate — `metadata.region:(apac OR emea OR amer)`; (b) query broadly with `with_field: ["metadata"]` (Tier 2) and filter client-side for entries where the field is set; (c) at ingest time, attach a sentinel tag whenever the field is set, then search by that tag.
71
+ * - **Newest / largest N** — keep the filter in `expression` and sort explicitly: `sort_by: [{uploaded_at: "desc"}]` with `max_results: 10`.
72
+ * - **Filter by folder** — both `asset_folder:"parent/child"` and `folder:"parent/child"` match an exact folder path; there is no wildcard or "contains". To query across multiple folders, enumerate: `asset_folder:("campaigns/2024" OR "campaigns/2025")`.
73
+ * - **Filter by metadata when you only know the label** — first call `list-metadata-fields` to resolve the label to an `external_id`, then query `metadata.<external_id>:value`.
74
+ * - **Multiple independent filters in one turn** — prefer one `expression` with `OR` / parentheses over firing many parallel calls: `metadata.region:apac OR metadata.region:emea` in a single request is faster and more reliable than two parallel requests.
75
+ *
76
+ * ## Examples
77
+ *
78
+ * - `tags:shirt AND uploaded_at>1d`
79
+ * - `resource_type:image AND bytes>1000000 AND (format:png OR format:jpg)`
80
+ * - `folder:products AND context.category:electronics`
81
+ * - `tags:"service:mantels" AND -tags:discontinued`
38
82
  */
39
83
  export function searchSearchAssets(
40
84
  client$: CloudinaryAssetMgmtCore,
@@ -19,8 +19,14 @@ export function landingPage(req: Request): Response {
19
19
 
20
20
  // express wrapper
21
21
  export function landingPageExpress(req: ExpressRequest, res: ExpressResponse) {
22
- const origin = new URL(req.host).href;
23
- res.type("html").send(landingPageHTML(origin));
22
+ const proto = req.get("x-forwarded-proto")?.split(",")[0]?.trim()
23
+ || req.protocol;
24
+ const host = req.get("host");
25
+ if (!host) {
26
+ res.status(400).send("Missing Host header");
27
+ return;
28
+ }
29
+ res.type("html").send(landingPageHTML(`${proto}://${host}`));
24
30
  }
25
31
 
26
32
  export function landingPageHTML(origin: string): string {
@@ -933,7 +939,7 @@ http_headers = { "api-key" = "YOUR_API_KEY", "api-secret" = "YOUR_API_SECRET", "
933
939
  <h1>Instructions</h1>
934
940
  <p>One-click installation for Claude Desktop users</p>
935
941
  <div class="instruction-item">
936
- <a href="https://github.com/cloudinary/asset-management-mcp/releases/download/v0.9.0/mcp-server.mcpb" download="mcp-server.mcpb" class="action-button header-action" style="display: inline-flex; margin-bottom: 16px;">
942
+ <a href="https://github.com/cloudinary/asset-management-mcp/releases/download/v0.9.2/mcp-server.mcpb" download="mcp-server.mcpb" class="action-button header-action" style="display: inline-flex; margin-bottom: 16px;">
937
943
  📥 Download MCP Bundle
938
944
  </a>
939
945
  </div>
package/src/lib/config.ts CHANGED
@@ -112,9 +112,9 @@ export function serverURLFromOptions(options: SDKOptions): URL | null {
112
112
 
113
113
  export const SDK_METADATA = {
114
114
  language: "typescript",
115
- openapiDocVersion: "0.5.0",
116
- sdkVersion: "0.9.0",
117
- genVersion: "2.881.4",
115
+ openapiDocVersion: "0.5.1",
116
+ sdkVersion: "0.9.2",
117
+ genVersion: "2.885.1",
118
118
  userAgent:
119
- "speakeasy-sdk/mcp-typescript 0.9.0 2.881.4 0.5.0 @cloudinary/asset-management-mcp",
119
+ "speakeasy-sdk/mcp-typescript 0.9.2 2.885.1 0.5.1 @cloudinary/asset-management-mcp",
120
120
  } as const;
@@ -116,6 +116,7 @@ export const SHARED_CSS_TOKENS = /* css */ `
116
116
  [data-theme="dark"] .status-warn, .dark .status-warn { background: #854d0e; color: #fef08a; }
117
117
  [data-theme="dark"] .status-err, .dark .status-err { background: #991b1b; color: #fecaca; }
118
118
 
119
+ html { overflow: hidden; }
119
120
  body {
120
121
  font-family: var(--cld-font);
121
122
  background: var(--cld-bg);
@@ -126,16 +127,35 @@ body {
126
127
  position: relative;
127
128
  }
128
129
  .theme-btn {
129
- position: absolute; top: 4px; right: 4px; z-index: 900;
130
130
  width: 22px; height: 22px; border-radius: 50%;
131
131
  border: 1px solid transparent; background: transparent;
132
132
  color: var(--cld-text3); cursor: pointer;
133
133
  display: flex; align-items: center; justify-content: center;
134
134
  padding: 0; transition: background 0.15s, color 0.15s, border-color 0.15s;
135
- opacity: 0.5;
135
+ opacity: 0.5; flex-shrink: 0;
136
136
  }
137
137
  .theme-btn:hover { background: var(--cld-bg3); color: var(--cld-text); border-color: var(--cld-border); opacity: 1; }
138
138
  .theme-btn svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
139
+ /* Shared icon-button — square pill, same family as theme-btn */
140
+ .icon-btn {
141
+ display: inline-flex; align-items: center; justify-content: center; gap: 5px;
142
+ background: none; border: 1px solid var(--cld-border); border-radius: var(--cld-radius-sm);
143
+ color: var(--cld-text2); cursor: pointer; padding: 4px 8px;
144
+ font-size: 12px; font-weight: 500; font-family: inherit; line-height: 1;
145
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
146
+ white-space: nowrap; flex-shrink: 0;
147
+ }
148
+ .icon-btn:hover { background: var(--cld-bg3); color: var(--cld-text); border-color: var(--cld-border2); }
149
+ .icon-btn svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex-shrink: 0; }
150
+ /* icon-only variant (no text label) */
151
+ .icon-btn.icon-only { padding: 4px; width: 28px; height: 28px; }
152
+ /* header state icon (decorative, not a button) */
153
+ .upload-header-icon { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
154
+ .upload-header-icon svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
155
+ .upload-header-icon.icon-success { color: var(--cld-success); }
156
+ .upload-header-icon.icon-error { color: var(--cld-error); }
157
+ .upload-header-icon.icon-warning { color: var(--cld-warning); }
158
+ .upload-header-icon.icon-accent { color: var(--cld-accent); }
139
159
  `;
140
160
 
141
161
  // ── CSS: Shared component styles ────────────────────────────────────
@@ -389,7 +409,8 @@ details.detail-section > summary.detail-section-title::-webkit-details-marker {
389
409
  }
390
410
  .upload-zone:hover { border-color: var(--cld-accent); background: var(--cld-accent-bg); }
391
411
  .upload-zone.dragover { border-color: var(--cld-accent); background: var(--cld-accent-bg); }
392
- .upload-zone-icon { font-size: 36px; margin-bottom: 8px; color: var(--cld-text3); }
412
+ .upload-zone-icon { margin-bottom: 8px; color: var(--cld-text3); display: flex; align-items: center; justify-content: center; }
413
+ .upload-zone-icon svg { width: 36px; height: 36px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
393
414
  .upload-zone-text { font-size: 14px; color: var(--cld-text2); margin-bottom: 4px; }
394
415
  .upload-zone-hint { font-size: 12px; color: var(--cld-text3); }
395
416
  .upload-zone-btn {
@@ -435,8 +456,9 @@ details.detail-section > summary.detail-section-title::-webkit-details-marker {
435
456
  .upload-preview-icon {
436
457
  width: 56px; height: 56px; border-radius: var(--cld-radius-sm);
437
458
  background: var(--cld-bg3); flex-shrink: 0; display: flex;
438
- align-items: center; justify-content: center; font-size: 24px; color: var(--cld-text3);
459
+ align-items: center; justify-content: center; color: var(--cld-text3);
439
460
  }
461
+ .upload-preview-icon svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
440
462
  .upload-preview-info { flex: 1; min-width: 0; }
441
463
  .upload-preview-name { font-size: 13px; font-weight: 600; color: var(--cld-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
442
464
  .upload-preview-meta { font-size: 11px; color: var(--cld-text3); margin-top: 2px; }
@@ -585,19 +607,15 @@ details.upload-section > .upload-form { margin: 0; padding: 10px 12px; }
585
607
  background: var(--cld-accent-bg); border: 1px solid var(--cld-accent);
586
608
  border-radius: var(--cld-radius); margin-bottom: 4px; position: relative;
587
609
  }
588
- .upload-staged-icon { font-size: 24px; flex-shrink: 0; }
610
+ .upload-staged-icon { flex-shrink: 0; display: flex; align-items: center; color: var(--cld-accent); }
611
+ .upload-staged-icon svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
589
612
  .upload-staged-info { flex: 1; min-width: 0; }
590
613
  .upload-staged-name {
591
614
  font-size: 13px; font-weight: 600; color: var(--cld-text);
592
615
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
593
616
  }
594
617
  .upload-staged-meta { font-size: 11px; color: var(--cld-text3); margin-top: 2px; }
595
- .upload-staged-clear {
596
- background: none; border: none; cursor: pointer; font-size: 16px;
597
- color: var(--cld-text3); padding: 4px 6px; border-radius: var(--cld-radius-sm);
598
- transition: color 0.15s, background 0.15s; flex-shrink: 0;
599
- }
600
- .upload-staged-clear:hover { color: var(--cld-error); background: rgba(206,25,13,0.08); }
618
+ .upload-staged-clear:hover { color: var(--cld-error); border-color: var(--cld-error); background: rgba(206,25,13,0.08); }
601
619
 
602
620
  /* Upload submit button */
603
621
  .upload-submit {
@@ -645,6 +663,24 @@ details.upload-section > .upload-form { margin: 0; padding: 10px 12px; }
645
663
  }
646
664
  `;
647
665
 
666
+ // ── JS: SVG icon strings (Lucide-style, 24px viewBox, stroke-based) ──
667
+ export const SHARED_JS_ICONS = /* js */ `
668
+ var IC = {
669
+ refresh: '<svg viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>',
670
+ chevronLeft:'<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"/></svg>',
671
+ arrowDown: '<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>',
672
+ x: '<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
673
+ zap: '<svg viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
674
+ uploadCloud:'<svg viewBox="0 0 24 24"><polyline points="16 16 12 12 8 16"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/></svg>',
675
+ alertTriangle:'<svg viewBox="0 0 24 24"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
676
+ folderOpen: '<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><polyline points="22 13 17 13 15 16 9 16 7 13 2 13"/></svg>',
677
+ checkCircle:'<svg viewBox="0 0 24 24"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
678
+ clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>',
679
+ file: '<svg viewBox="0 0 24 24"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>',
680
+ image: '<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
681
+ };
682
+ `;
683
+
648
684
  // ── JS: MCPApp client class ─────────────────────────────────────────
649
685
  export const SHARED_JS_MCP_CLIENT = /* js */ `
650
686
  var RPC_TIMEOUT_MS = 15000;
@@ -734,6 +770,29 @@ class MCPApp {
734
770
 
735
771
  // ── JS: Helper functions ────────────────────────────────────────────
736
772
  export const SHARED_JS_HELPERS = /* js */ `
773
+ function copyText(text) {
774
+ if (navigator.clipboard && navigator.clipboard.writeText) {
775
+ return navigator.clipboard.writeText(text).catch(function() { return _copyFallback(text); });
776
+ }
777
+ return _copyFallback(text);
778
+ }
779
+ function _copyFallback(text) {
780
+ return new Promise(function(resolve, reject) {
781
+ try {
782
+ var ta = document.createElement("textarea");
783
+ ta.value = text;
784
+ ta.setAttribute("readonly", "");
785
+ ta.style.position = "fixed"; ta.style.top = "0"; ta.style.left = "0";
786
+ ta.style.opacity = "0"; ta.style.pointerEvents = "none";
787
+ document.body.appendChild(ta);
788
+ ta.select(); ta.setSelectionRange(0, text.length);
789
+ var ok = document.execCommand("copy");
790
+ document.body.removeChild(ta);
791
+ if (ok) resolve(); else reject(new Error("Clipboard unavailable"));
792
+ } catch (e) { reject(e); }
793
+ });
794
+ }
795
+
737
796
  function fmtBytes(b) {
738
797
  if (!b) return "";
739
798
  var u = ["B","KB","MB","GB"], i = Math.min(Math.floor(Math.log(b)/Math.log(1024)), 3);
@@ -1966,7 +2025,16 @@ function renderThemeToggle() {
1966
2025
  applyTheme();
1967
2026
  renderThemeToggle();
1968
2027
  });
1969
- document.body.appendChild(btn);
2028
+ var slot = document.getElementById("header-actions");
2029
+ if (slot) {
2030
+ slot.appendChild(btn);
2031
+ } else {
2032
+ btn.style.position = "absolute";
2033
+ btn.style.top = "4px";
2034
+ btn.style.right = "4px";
2035
+ btn.style.zIndex = "900";
2036
+ document.body.appendChild(btn);
2037
+ }
1970
2038
  }
1971
2039
 
1972
2040
  function setupHostContext(app) {
@@ -9,6 +9,7 @@
9
9
  import {
10
10
  SHARED_CSS_TOKENS,
11
11
  SHARED_CSS_COMPONENTS,
12
+ SHARED_JS_ICONS,
12
13
  SHARED_JS_MCP_CLIENT,
13
14
  SHARED_JS_HELPERS,
14
15
  SHARED_JS_TOOLTIPS,
@@ -59,16 +60,6 @@ const ASSET_DETAILS_CSS = /* css */ `
59
60
  padding: 2px 7px; border-radius: 4px; border: 1px solid var(--cld-border);
60
61
  }
61
62
 
62
- .open-link {
63
- padding: 6px 14px; border-radius: var(--cld-radius-sm);
64
- font-size: 12px; font-weight: 500; cursor: pointer;
65
- border: 1px solid var(--cld-accent); background: transparent;
66
- color: var(--cld-accent); font-family: inherit;
67
- transition: background 0.15s;
68
- white-space: nowrap; flex-shrink: 0;
69
- }
70
- .open-link:hover { background: var(--cld-accent-bg); }
71
-
72
63
  .hero-container {
73
64
  position: relative; margin-bottom: var(--cld-sp-md);
74
65
  border-radius: var(--cld-radius); overflow: hidden;
@@ -131,9 +122,9 @@ function renderPage(r) {
131
122
  if (dur) h += '<span class="pill">' + dur + "</span>";
132
123
  if (size) h += '<span class="pill">' + size + "</span>";
133
124
  h += "</div></div>";
134
- h += '<div style="display:flex;gap:6px;flex-shrink:0">';
135
- h += '<button class="open-link" id="refresh-asset" title="Refresh">\\u21BB</button>';
136
- if (url) h += '<button class="open-link" id="open-asset">Open</button>';
125
+ h += '<div id="header-actions" style="display:flex;gap:8px;flex-shrink:0;align-items:center">';
126
+ if (url) h += '<button class="icon-btn" id="open-asset">Open</button>';
127
+ h += '<button class="icon-btn icon-only" id="refresh-asset" title="Refresh">' + IC.refresh + '</button>';
137
128
  h += "</div>";
138
129
  h += "</div>";
139
130
 
@@ -175,6 +166,7 @@ function renderPage(r) {
175
166
  h += "</div>";
176
167
 
177
168
  root.innerHTML = h;
169
+ renderThemeToggle();
178
170
 
179
171
  // Event delegation
180
172
  root.addEventListener("click", function handler(e) {
@@ -220,6 +212,7 @@ function showFetchPrompt() {
220
212
  h += '<button class="prompt-btn prompt-btn-primary" id="fetch-direct-btn">Fetch Directly</button>';
221
213
  h += "</div></div>";
222
214
  root.innerHTML = h;
215
+ renderThemeToggle();
223
216
  document.getElementById("fetch-direct-btn").addEventListener("click", function() { fetchDirect(); });
224
217
  }
225
218
 
@@ -300,6 +293,7 @@ ${ASSET_DETAILS_CSS}
300
293
  <div id="app"><div class="status">Loading asset details&hellip;</div></div>
301
294
 
302
295
  <script>
296
+ ${SHARED_JS_ICONS}
303
297
  ${SHARED_JS_MCP_CLIENT}
304
298
  ${SHARED_JS_HELPERS}
305
299
  ${SHARED_JS_TOOLTIPS}