diff --git a/web/src/app/home/add-extension/page.tsx b/web/src/app/home/add-extension/page.tsx
index f03acf3a..358f37da 100644
--- a/web/src/app/home/add-extension/page.tsx
+++ b/web/src/app/home/add-extension/page.tsx
@@ -192,6 +192,13 @@ function AddExtensionContent() {
);
})();
+ // When the resolved icon URL changes (e.g. the real external icon arrives
+ // after an async fetch), clear any prior load failure so the
retries
+ // instead of staying on the placeholder.
+ useEffect(() => {
+ setInstallIconFailed(false);
+ }, [installIconURL]);
+
const [popoverOpen, setPopoverOpen] = useState(false);
const [popoverView, setPopoverView] = useState('menu');
const [isDragOver, setIsDragOver] = useState(false);
@@ -279,6 +286,23 @@ function AddExtensionContent() {
setInstallIconFailed(false);
setModalOpen(true);
+ // The icon is not carried in the URL params, so fetch it from the
+ // marketplace record. Without this the confirm dialog falls back to the
+ // /resources/icon endpoint, which 404s for extensions whose icon is an
+ // external URL (simpleicons / iconify), showing a placeholder.
+ const cloud = getCloudServiceClientSync();
+ cloud
+ .fetchMarketplaceIcon(extType, author, name)
+ .then((icon) => {
+ if (!icon) return;
+ setInstallInfo((prev) =>
+ prev.plugin_author === author && prev.plugin_name === name
+ ? { ...prev, plugin_icon: icon }
+ : prev,
+ );
+ })
+ .catch(() => {});
+
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
diff --git a/web/src/app/infra/http/CloudServiceClient.ts b/web/src/app/infra/http/CloudServiceClient.ts
index e1c5b33d..e0568978 100644
--- a/web/src/app/infra/http/CloudServiceClient.ts
+++ b/web/src/app/infra/http/CloudServiceClient.ts
@@ -207,6 +207,52 @@ export class CloudServiceClient extends BaseHttpClient {
);
}
+ public getMCPDetail(
+ author: string,
+ name: string,
+ ): Promise<{ mcp: PluginV4 }> {
+ return this.get<{ mcp: PluginV4 }>(
+ `/api/v1/marketplace/mcps/${author}/${name}`,
+ );
+ }
+
+ public getSkillDetail(
+ author: string,
+ name: string,
+ ): Promise<{ skill: PluginV4 }> {
+ return this.get<{ skill: PluginV4 }>(
+ `/api/v1/marketplace/skills/${author}/${name}`,
+ );
+ }
+
+ /**
+ * Resolve the marketplace ``icon`` field for an extension by author/name/type.
+ * Used when the icon is not already known locally (e.g. an install confirm
+ * dialog opened from a URL query param carries no icon). Returns the raw
+ * ``icon`` value from the marketplace record (often an absolute external URL),
+ * or an empty string if it cannot be fetched.
+ */
+ public async fetchMarketplaceIcon(
+ type: 'plugin' | 'mcp' | 'skill' | undefined,
+ author: string,
+ name: string,
+ ): Promise {
+ try {
+ if (type === 'mcp') {
+ const resp = await this.getMCPDetail(author, name);
+ return resp?.mcp?.icon || '';
+ }
+ if (type === 'skill') {
+ const resp = await this.getSkillDetail(author, name);
+ return resp?.skill?.icon || '';
+ }
+ const resp = await this.getPluginDetail(author, name);
+ return resp?.plugin?.icon || '';
+ } catch {
+ return '';
+ }
+ }
+
public getPluginREADME(
author: string,
pluginName: string,