Files
sproutclaw-data/agent/extensions/pi-mcp-adapter/ui-resource-handler.ts
shumengya 50edff80f5 feat: 导出 SproutClaw .sproutclaw 配置
包含 extensions、skills、prompts、settings、auth、models、mcp 等配置。
排除 node_modules、npm 缓存、sessions 等运行时数据。
2026-06-26 15:48:56 +08:00

147 lines
4.9 KiB
TypeScript

import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge";
import { UrlElicitationRequiredError, type ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
import { ResourceFetchError, ResourceParseError } from "./errors.ts";
import { logger } from "./logger.ts";
import type { McpServerManager } from "./server-manager.ts";
import type { UiResourceContent, UiResourceMeta } from "./types.ts";
interface ResourceContentRecord {
uri?: string;
mimeType?: string;
text?: string;
blob?: string;
_meta?: Record<string, unknown>;
}
export class UiResourceHandler {
private log = logger.child({ component: "UiResourceHandler" });
constructor(private manager: McpServerManager) {}
async readUiResource(serverName: string, uri: string): Promise<UiResourceContent> {
const log = this.log.child({ server: serverName, uri });
if (!uri.startsWith("ui://")) {
throw new ResourceParseError(uri, "URI must start with ui://", { server: serverName });
}
log.debug("Fetching UI resource");
let result: ReadResourceResult;
try {
result = await this.manager.readResource(serverName, uri);
} catch (error) {
if (error instanceof UrlElicitationRequiredError) throw error;
const message = error instanceof Error ? error.message : String(error);
log.error("Failed to read resource", error instanceof Error ? error : undefined);
throw new ResourceFetchError(uri, message, {
server: serverName,
cause: error instanceof Error ? error : undefined,
});
}
const content = selectContent(result, uri);
const mimeType = content.mimeType;
if (mimeType && !isHtmlMimeType(mimeType)) {
log.warn("Unsupported MIME type", { mimeType });
throw new ResourceParseError(
uri,
`unsupported MIME type "${mimeType}" (expected text/html or ${RESOURCE_MIME_TYPE})`,
{ server: serverName, mimeType }
);
}
const html = toHtml(content);
if (!html.trim()) {
log.warn("Resource content is empty");
throw new ResourceParseError(uri, "content is empty", { server: serverName });
}
const contentMeta = extractUiMeta(content._meta);
const listMeta = extractUiMeta(this.getListResourceMeta(serverName, uri));
log.debug("Resource loaded successfully", {
contentLength: html.length,
hasCsp: !!contentMeta.csp || !!listMeta.csp,
});
return {
uri: content.uri ?? uri,
html,
mimeType: mimeType ?? RESOURCE_MIME_TYPE,
meta: {
csp: contentMeta.csp ?? listMeta.csp,
permissions: contentMeta.permissions ?? listMeta.permissions,
domain: contentMeta.domain ?? listMeta.domain,
prefersBorder: contentMeta.prefersBorder ?? listMeta.prefersBorder,
},
};
}
private getListResourceMeta(serverName: string, uri: string): Record<string, unknown> | undefined {
const connection = this.manager.getConnection(serverName);
if (!connection?.resources?.length) return undefined;
const resource = connection.resources.find((entry) => entry.uri === uri);
if (!resource || !resource._meta || typeof resource._meta !== "object") return undefined;
return resource._meta;
}
}
function selectContent(result: ReadResourceResult, preferredUri: string): ResourceContentRecord {
const contents = (result.contents ?? []) as ResourceContentRecord[];
if (contents.length === 0) {
throw new Error(`No contents returned for UI resource: ${preferredUri}`);
}
const byUri = contents.find((content) => content.uri === preferredUri);
if (byUri) return byUri;
const byHtmlMime = contents.find(
(content) => content.mimeType && isHtmlMimeType(content.mimeType)
);
if (byHtmlMime) return byHtmlMime;
return contents[0];
}
function isHtmlMimeType(mimeType: string): boolean {
const normalized = mimeType.toLowerCase();
return normalized.startsWith("text/html") || normalized === RESOURCE_MIME_TYPE.toLowerCase();
}
function toHtml(content: ResourceContentRecord): string {
if (typeof content.text === "string") {
return content.text;
}
if (typeof content.blob === "string") {
return Buffer.from(content.blob, "base64").toString("utf-8");
}
throw new Error(`UI resource ${content.uri ?? "(unknown)"} did not include text or blob content`);
}
function extractUiMeta(meta: Record<string, unknown> | undefined): UiResourceMeta {
if (!meta || typeof meta !== "object") return {};
const ui = meta.ui as Record<string, unknown> | undefined;
if (!ui || typeof ui !== "object") return {};
const out: UiResourceMeta = {};
if (ui.csp && typeof ui.csp === "object") {
out.csp = ui.csp as UiResourceMeta["csp"];
}
if (ui.permissions && typeof ui.permissions === "object") {
out.permissions = ui.permissions as UiResourceMeta["permissions"];
}
if (typeof ui.domain === "string") {
out.domain = ui.domain;
}
if (typeof ui.prefersBorder === "boolean") {
out.prefersBorder = ui.prefersBorder;
}
return out;
}