pi-mcp-adapter 2.1.2 → 2.2.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.
package/server-manager.ts CHANGED
@@ -1,11 +1,19 @@
1
- // server-manager.ts - MCP connection management (stdio + HTTP)
2
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
2
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4
3
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
4
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
6
- import type { McpTool, McpResource, ServerDefinition, Transport } from "./types.js";
5
+ import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
6
+ import type {
7
+ McpTool,
8
+ McpResource,
9
+ ServerDefinition,
10
+ ServerStreamResultPatchNotification,
11
+ Transport,
12
+ } from "./types.js";
13
+ import { serverStreamResultPatchNotificationSchema } from "./types.js";
7
14
  import { getStoredTokens } from "./oauth-handler.js";
8
15
  import { resolveNpxBinary } from "./npx-resolver.js";
16
+ import { logger } from "./logger.js";
9
17
 
10
18
  interface ServerConnection {
11
19
  client: Client;
@@ -18,9 +26,12 @@ interface ServerConnection {
18
26
  status: "connected" | "closed";
19
27
  }
20
28
 
29
+ type UiStreamListener = (serverName: string, notification: ServerStreamResultPatchNotification["params"]) => void;
30
+
21
31
  export class McpServerManager {
22
32
  private connections = new Map<string, ServerConnection>();
23
33
  private connectPromises = new Map<string, Promise<ServerConnection>>();
34
+ private uiStreamListeners = new Map<string, UiStreamListener>();
24
35
 
25
36
  async connect(name: string, definition: ServerDefinition): Promise<ServerConnection> {
26
37
  // Dedupe concurrent connection attempts
@@ -64,7 +75,7 @@ export class McpServerManager {
64
75
  if (resolved) {
65
76
  command = resolved.isJs ? "node" : resolved.binPath;
66
77
  args = resolved.isJs ? [resolved.binPath, ...resolved.extraArgs] : resolved.extraArgs;
67
- console.log(`MCP: ${name} resolved to ${resolved.binPath} (skipping npm parent)`);
78
+ logger.debug(`${name} resolved to ${resolved.binPath} (skipping npm parent)`);
68
79
  }
69
80
  }
70
81
 
@@ -84,6 +95,7 @@ export class McpServerManager {
84
95
 
85
96
  try {
86
97
  await client.connect(transport);
98
+ this.attachAdapterNotificationHandlers(name, client);
87
99
 
88
100
  // Discover tools and resources
89
101
  const [tools, resources] = await Promise.all([
@@ -190,6 +202,38 @@ export class McpServerManager {
190
202
  return [];
191
203
  }
192
204
  }
205
+
206
+ private attachAdapterNotificationHandlers(serverName: string, client: Client): void {
207
+ client.setNotificationHandler(serverStreamResultPatchNotificationSchema, (notification) => {
208
+ const listener = this.uiStreamListeners.get(notification.params.streamToken);
209
+ if (!listener) return;
210
+ listener(serverName, notification.params);
211
+ });
212
+ }
213
+
214
+ registerUiStreamListener(streamToken: string, listener: UiStreamListener): void {
215
+ this.uiStreamListeners.set(streamToken, listener);
216
+ }
217
+
218
+ removeUiStreamListener(streamToken: string): void {
219
+ this.uiStreamListeners.delete(streamToken);
220
+ }
221
+
222
+ async readResource(name: string, uri: string): Promise<ReadResourceResult> {
223
+ const connection = this.connections.get(name);
224
+ if (!connection || connection.status !== "connected") {
225
+ throw new Error(`Server "${name}" is not connected`);
226
+ }
227
+
228
+ try {
229
+ this.touch(name);
230
+ this.incrementInFlight(name);
231
+ return await connection.client.readResource({ uri });
232
+ } finally {
233
+ this.decrementInFlight(name);
234
+ this.touch(name);
235
+ }
236
+ }
193
237
 
194
238
  async close(name: string): Promise<void> {
195
239
  const connection = this.connections.get(name);
@@ -241,7 +285,7 @@ export class McpServerManager {
241
285
  isIdle(name: string, timeoutMs: number): boolean {
242
286
  const connection = this.connections.get(name);
243
287
  if (!connection || connection.status !== "connected") return false;
244
- if (connection.inFlight && connection.inFlight > 0) return false;
288
+ if (connection.inFlight > 0) return false;
245
289
  return (Date.now() - connection.lastUsedAt) > timeoutMs;
246
290
  }
247
291
  }
package/state.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { ConsentManager } from "./consent-manager.js";
3
+ import type { McpLifecycleManager } from "./lifecycle.js";
4
+ import type { McpServerManager } from "./server-manager.js";
5
+ import type { ToolMetadata, McpConfig, UiSessionMessages, UiStreamSummary } from "./types.js";
6
+ import type { UiResourceHandler } from "./ui-resource-handler.js";
7
+ import type { UiServerHandle } from "./ui-server.js";
8
+
9
+ export interface CompletedUiSession {
10
+ serverName: string;
11
+ toolName: string;
12
+ completedAt: Date;
13
+ reason: string;
14
+ messages: UiSessionMessages;
15
+ stream?: UiStreamSummary;
16
+ }
17
+
18
+ export type SendMessageFn = (
19
+ message: {
20
+ customType: string;
21
+ content: Array<{ type: string; text: string }>;
22
+ display?: string;
23
+ details?: unknown;
24
+ },
25
+ options?: { triggerTurn?: boolean }
26
+ ) => void;
27
+
28
+ export interface McpExtensionState {
29
+ manager: McpServerManager;
30
+ lifecycle: McpLifecycleManager;
31
+ toolMetadata: Map<string, ToolMetadata[]>;
32
+ config: McpConfig;
33
+ failureTracker: Map<string, number>;
34
+ uiResourceHandler: UiResourceHandler;
35
+ consentManager: ConsentManager;
36
+ uiServer: UiServerHandle | null;
37
+ completedUiSessions: CompletedUiSession[];
38
+ openBrowser: (url: string) => Promise<void>;
39
+ ui?: ExtensionContext["ui"];
40
+ sendMessage?: SendMessageFn;
41
+ }
@@ -0,0 +1,144 @@
1
+ import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
2
+ import type { McpExtensionState } from "./state.js";
3
+ import type { ToolMetadata, McpTool, McpResource, ServerEntry } from "./types.js";
4
+ import { formatToolName } from "./types.js";
5
+ import { resourceNameToToolName } from "./resource-tools.js";
6
+ import { extractToolUiStreamMode } from "./utils.js";
7
+
8
+ export function buildToolMetadata(
9
+ tools: McpTool[],
10
+ resources: McpResource[],
11
+ definition: ServerEntry,
12
+ serverName: string,
13
+ prefix: "server" | "none" | "short"
14
+ ): { metadata: ToolMetadata[]; failedTools: string[] } {
15
+ const metadata: ToolMetadata[] = [];
16
+ const failedTools: string[] = [];
17
+
18
+ for (const tool of tools) {
19
+ if (!tool?.name) {
20
+ failedTools.push("(unnamed)");
21
+ continue;
22
+ }
23
+ let uiResourceUri: string | undefined;
24
+ try {
25
+ uiResourceUri = getToolUiResourceUri({ _meta: tool._meta });
26
+ } catch {
27
+ failedTools.push(tool.name);
28
+ }
29
+ metadata.push({
30
+ name: formatToolName(tool.name, serverName, prefix),
31
+ originalName: tool.name,
32
+ description: tool.description ?? "",
33
+ inputSchema: tool.inputSchema,
34
+ uiResourceUri,
35
+ uiStreamMode: extractToolUiStreamMode(tool._meta),
36
+ });
37
+ }
38
+
39
+ if (definition.exposeResources !== false) {
40
+ for (const resource of resources) {
41
+ const baseName = `get_${resourceNameToToolName(resource.name)}`;
42
+ metadata.push({
43
+ name: formatToolName(baseName, serverName, prefix),
44
+ originalName: baseName,
45
+ description: resource.description ?? `Read resource: ${resource.uri}`,
46
+ resourceUri: resource.uri,
47
+ });
48
+ }
49
+ }
50
+
51
+ return { metadata, failedTools };
52
+ }
53
+
54
+ export function getToolNames(state: McpExtensionState, serverName: string): string[] {
55
+ return state.toolMetadata.get(serverName)?.map(m => m.name) ?? [];
56
+ }
57
+
58
+ export function totalToolCount(state: McpExtensionState): number {
59
+ let count = 0;
60
+ for (const metadata of state.toolMetadata.values()) {
61
+ count += metadata.length;
62
+ }
63
+ return count;
64
+ }
65
+
66
+ export function findToolByName(metadata: ToolMetadata[] | undefined, toolName: string): ToolMetadata | undefined {
67
+ if (!metadata) return undefined;
68
+ const exact = metadata.find(m => m.name === toolName);
69
+ if (exact) return exact;
70
+ const normalized = toolName.replace(/-/g, "_");
71
+ return metadata.find(m => m.name.replace(/-/g, "_") === normalized);
72
+ }
73
+
74
+ export function formatSchema(schema: unknown, indent = " "): string {
75
+ if (!schema || typeof schema !== "object") {
76
+ return `${indent}(no schema)`;
77
+ }
78
+
79
+ const s = schema as Record<string, unknown>;
80
+
81
+ if (s.type === "object" && s.properties && typeof s.properties === "object") {
82
+ const props = s.properties as Record<string, unknown>;
83
+ const required = Array.isArray(s.required) ? s.required as string[] : [];
84
+
85
+ if (Object.keys(props).length === 0) {
86
+ return `${indent}(no parameters)`;
87
+ }
88
+
89
+ const lines: string[] = [];
90
+ for (const [name, propSchema] of Object.entries(props)) {
91
+ const isRequired = required.includes(name);
92
+ const propLine = formatProperty(name, propSchema, isRequired, indent);
93
+ lines.push(propLine);
94
+ }
95
+ return lines.join("\n");
96
+ }
97
+
98
+ if (s.type) {
99
+ return `${indent}(${s.type})`;
100
+ }
101
+
102
+ return `${indent}(complex schema)`;
103
+ }
104
+
105
+ function formatProperty(name: string, schema: unknown, required: boolean, indent: string): string {
106
+ if (!schema || typeof schema !== "object") {
107
+ return `${indent}${name}${required ? " *required*" : ""}`;
108
+ }
109
+
110
+ const s = schema as Record<string, unknown>;
111
+ const parts: string[] = [];
112
+
113
+ let typeStr = "";
114
+ if (s.type) {
115
+ if (Array.isArray(s.type)) {
116
+ typeStr = s.type.join(" | ");
117
+ } else {
118
+ typeStr = String(s.type);
119
+ }
120
+ } else if (s.enum) {
121
+ typeStr = "enum";
122
+ } else if (s.anyOf || s.oneOf) {
123
+ typeStr = "union";
124
+ }
125
+
126
+ if (Array.isArray(s.enum)) {
127
+ const enumVals = s.enum.map(v => JSON.stringify(v)).join(", ");
128
+ typeStr = `enum: ${enumVals}`;
129
+ }
130
+
131
+ parts.push(`${indent}${name}`);
132
+ if (typeStr) parts.push(`(${typeStr})`);
133
+ if (required) parts.push("*required*");
134
+
135
+ if (s.description && typeof s.description === "string") {
136
+ parts.push(`- ${s.description}`);
137
+ }
138
+
139
+ if (s.default !== undefined) {
140
+ parts.push(`[default: ${JSON.stringify(s.default)}]`);
141
+ }
142
+
143
+ return parts.join(" ");
144
+ }
package/types.ts CHANGED
@@ -25,6 +25,7 @@ export interface McpTool {
25
25
  title?: string;
26
26
  description?: string;
27
27
  inputSchema?: unknown; // JSON Schema
28
+ _meta?: Record<string, unknown>;
28
29
  }
29
30
 
30
31
  // Resource definition from MCP server
@@ -33,6 +34,212 @@ export interface McpResource {
33
34
  name: string;
34
35
  description?: string;
35
36
  mimeType?: string;
37
+ _meta?: Record<string, unknown>;
38
+ }
39
+
40
+ export interface UiResourceMeta {
41
+ csp?: UiResourceCsp;
42
+ permissions?: UiResourcePermissions;
43
+ domain?: string;
44
+ prefersBorder?: boolean;
45
+ }
46
+
47
+ export interface UiResourceContent {
48
+ uri: string;
49
+ html: string;
50
+ mimeType?: string;
51
+ meta: UiResourceMeta;
52
+ }
53
+
54
+ export interface UiProxyRequestBody<TParams> {
55
+ token: string;
56
+ params: TParams;
57
+ }
58
+
59
+ export interface UiProxyResult<T = Record<string, unknown>> {
60
+ ok: boolean;
61
+ result?: T;
62
+ error?: string;
63
+ }
64
+
65
+ export interface UiResourceCsp {
66
+ connectDomains?: string[];
67
+ scriptDomains?: string[];
68
+ styleDomains?: string[];
69
+ fontDomains?: string[];
70
+ imgDomains?: string[];
71
+ mediaDomains?: string[];
72
+ frameDomains?: string[];
73
+ workerDomains?: string[];
74
+ baseUriDomains?: string[];
75
+ }
76
+
77
+ export interface UiResourcePermissions {
78
+ camera?: {};
79
+ microphone?: {};
80
+ geolocation?: {};
81
+ clipboardWrite?: {};
82
+ }
83
+
84
+ export interface UiToolInfo {
85
+ id?: string | number;
86
+ tool: {
87
+ name: string;
88
+ description?: string;
89
+ inputSchema?: unknown;
90
+ };
91
+ }
92
+
93
+ export interface UiHostContext {
94
+ toolInfo?: UiToolInfo;
95
+ theme?: "light" | "dark";
96
+ styles?: Record<string, unknown>;
97
+ displayMode?: UiDisplayMode;
98
+ availableDisplayModes?: UiDisplayMode[];
99
+ containerDimensions?: {
100
+ width?: number;
101
+ maxWidth?: number;
102
+ height?: number;
103
+ maxHeight?: number;
104
+ };
105
+ [key: string]: unknown;
106
+ }
107
+
108
+ export type UiDisplayMode = "inline" | "fullscreen" | "pip";
109
+
110
+ // Re-export stream types from the shared lightweight module.
111
+ // This allows the example package to import stream schemas without pulling the full types.ts dependency graph.
112
+ export {
113
+ UI_STREAM_HOST_CONTEXT_KEY,
114
+ UI_STREAM_REQUEST_META_KEY,
115
+ UI_STREAM_RESULT_PATCH_METHOD,
116
+ SERVER_STREAM_RESULT_PATCH_METHOD,
117
+ UI_STREAM_STRUCTURED_CONTENT_KEY,
118
+ uiStreamModeSchema,
119
+ visualizationStreamPhaseSchema,
120
+ visualizationStreamFrameTypeSchema,
121
+ visualizationStreamStatusSchema,
122
+ uiStreamHostContextSchema,
123
+ visualizationStreamEnvelopeSchema,
124
+ uiStreamCallToolResultSchema,
125
+ uiStreamResultPatchNotificationSchema,
126
+ serverStreamResultPatchNotificationSchema,
127
+ getUiStreamHostContext,
128
+ getVisualizationStreamEnvelope,
129
+ type UiStreamMode,
130
+ type VisualizationStreamPhase,
131
+ type VisualizationStreamFrameType,
132
+ type VisualizationStreamStatus,
133
+ type UiStreamHostContext,
134
+ type VisualizationStreamEnvelope,
135
+ type UiStreamCallToolResult,
136
+ type UiStreamResultPatchNotification,
137
+ type ServerStreamResultPatchNotification,
138
+ type UiStreamSummary,
139
+ } from "./ui-stream-types.js";
140
+
141
+ export interface UiMessageParams {
142
+ role?: string;
143
+ content?: unknown[];
144
+ type?: "prompt" | "notify" | "intent" | "message";
145
+ message?: string;
146
+ prompt?: string;
147
+ intent?: string;
148
+ params?: Record<string, unknown>;
149
+ [key: string]: unknown;
150
+ }
151
+
152
+ /**
153
+ * Extract prompt text from either legacy MCP UI message shapes or native AppBridge user messages.
154
+ */
155
+ export function extractUiPromptText(params: UiMessageParams): string | undefined {
156
+ if (params.type === "prompt" || params.prompt) {
157
+ const prompt = params.prompt ?? String(params.message ?? "");
158
+ return prompt || undefined;
159
+ }
160
+
161
+ if (params.role === "user" && Array.isArray(params.content)) {
162
+ const text = params.content
163
+ .map((block) => (block && typeof block === "object" && "text" in block ? String((block as { text?: unknown }).text ?? "") : ""))
164
+ .filter(Boolean)
165
+ .join("\n\n");
166
+ return text || undefined;
167
+ }
168
+
169
+ return undefined;
170
+ }
171
+
172
+ /**
173
+ * Structured UI handoff recovered from a canonical prompt envelope.
174
+ */
175
+ export interface UiPromptHandoff {
176
+ intent: string;
177
+ params: Record<string, unknown>;
178
+ raw: string;
179
+ }
180
+
181
+ /**
182
+ * Parse a canonical named UI handoff encoded as `intent\n{json}`.
183
+ */
184
+ export function parseUiPromptHandoff(prompt: string): UiPromptHandoff | undefined {
185
+ const newlineIndex = prompt.indexOf("\n");
186
+ if (newlineIndex <= 0) {
187
+ return undefined;
188
+ }
189
+
190
+ const intent = prompt.slice(0, newlineIndex).trim();
191
+ const payloadText = prompt.slice(newlineIndex + 1).trim();
192
+ if (!intent || !payloadText) {
193
+ return undefined;
194
+ }
195
+
196
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(intent)) {
197
+ return undefined;
198
+ }
199
+
200
+ try {
201
+ const parsed = JSON.parse(payloadText);
202
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
203
+ return undefined;
204
+ }
205
+ return {
206
+ intent,
207
+ params: parsed as Record<string, unknown>,
208
+ raw: prompt,
209
+ };
210
+ } catch {
211
+ return undefined;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Accumulated messages from a UI session.
217
+ * Collected during the session and available when it ends.
218
+ */
219
+ export interface UiSessionMessages {
220
+ prompts: string[];
221
+ notifications: string[];
222
+ intents: Array<{ intent: string; params?: Record<string, unknown> }>;
223
+ }
224
+
225
+ export interface UiModelContextParams {
226
+ content?: unknown[];
227
+ structuredContent?: Record<string, unknown>;
228
+ [key: string]: unknown;
229
+ }
230
+
231
+ export interface UiOpenLinkResult {
232
+ isError?: boolean;
233
+ [key: string]: unknown;
234
+ }
235
+
236
+ export interface UiDisplayModeRequest {
237
+ mode?: UiDisplayMode;
238
+ }
239
+
240
+ export interface UiDisplayModeResult {
241
+ mode: UiDisplayMode;
242
+ [key: string]: unknown;
36
243
  }
37
244
 
38
245
  // Content types from MCP
@@ -98,7 +305,9 @@ export interface ToolMetadata {
98
305
  originalName: string; // Original MCP tool name (e.g., "list_sims")
99
306
  description: string;
100
307
  resourceUri?: string; // For resource tools: the URI to read
308
+ uiResourceUri?: string; // For app-enabled tools: the UI resource URI
101
309
  inputSchema?: unknown; // JSON Schema for parameters (stored for describe/errors)
310
+ uiStreamMode?: UiStreamMode;
102
311
  }
103
312
 
104
313
  export interface DirectToolSpec {
@@ -108,6 +317,8 @@ export interface DirectToolSpec {
108
317
  description: string;
109
318
  inputSchema?: unknown;
110
319
  resourceUri?: string;
320
+ uiResourceUri?: string;
321
+ uiStreamMode?: UiStreamMode;
111
322
  }
112
323
 
113
324
  export interface ServerProvenance {
@@ -0,0 +1,145 @@
1
+ import { RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/app-bridge";
2
+ import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
3
+ import { ResourceFetchError, ResourceParseError } from "./errors.js";
4
+ import { logger } from "./logger.js";
5
+ import type { McpServerManager } from "./server-manager.js";
6
+ import type { UiResourceContent, UiResourceMeta } from "./types.js";
7
+
8
+ interface ResourceContentRecord {
9
+ uri?: string;
10
+ mimeType?: string;
11
+ text?: string;
12
+ blob?: string;
13
+ _meta?: Record<string, unknown>;
14
+ }
15
+
16
+ export class UiResourceHandler {
17
+ private log = logger.child({ component: "UiResourceHandler" });
18
+
19
+ constructor(private manager: McpServerManager) {}
20
+
21
+ async readUiResource(serverName: string, uri: string): Promise<UiResourceContent> {
22
+ const log = this.log.child({ server: serverName, uri });
23
+
24
+ if (!uri.startsWith("ui://")) {
25
+ throw new ResourceParseError(uri, "URI must start with ui://", { server: serverName });
26
+ }
27
+
28
+ log.debug("Fetching UI resource");
29
+
30
+ let result: ReadResourceResult;
31
+ try {
32
+ result = await this.manager.readResource(serverName, uri);
33
+ } catch (error) {
34
+ const message = error instanceof Error ? error.message : String(error);
35
+ log.error("Failed to read resource", error instanceof Error ? error : undefined);
36
+ throw new ResourceFetchError(uri, message, {
37
+ server: serverName,
38
+ cause: error instanceof Error ? error : undefined,
39
+ });
40
+ }
41
+
42
+ const content = selectContent(result, uri);
43
+ const mimeType = content.mimeType;
44
+
45
+ if (mimeType && !isHtmlMimeType(mimeType)) {
46
+ log.warn("Unsupported MIME type", { mimeType });
47
+ throw new ResourceParseError(
48
+ uri,
49
+ `unsupported MIME type "${mimeType}" (expected text/html or ${RESOURCE_MIME_TYPE})`,
50
+ { server: serverName, mimeType }
51
+ );
52
+ }
53
+
54
+ const html = toHtml(content);
55
+ if (!html.trim()) {
56
+ log.warn("Resource content is empty");
57
+ throw new ResourceParseError(uri, "content is empty", { server: serverName });
58
+ }
59
+
60
+ const contentMeta = extractUiMeta(content._meta);
61
+ const listMeta = extractUiMeta(this.getListResourceMeta(serverName, uri));
62
+
63
+ log.debug("Resource loaded successfully", {
64
+ contentLength: html.length,
65
+ hasCsp: !!contentMeta.csp || !!listMeta.csp,
66
+ });
67
+
68
+ return {
69
+ uri: content.uri ?? uri,
70
+ html,
71
+ mimeType: mimeType ?? RESOURCE_MIME_TYPE,
72
+ meta: {
73
+ csp: contentMeta.csp ?? listMeta.csp,
74
+ permissions: contentMeta.permissions ?? listMeta.permissions,
75
+ domain: contentMeta.domain ?? listMeta.domain,
76
+ prefersBorder: contentMeta.prefersBorder ?? listMeta.prefersBorder,
77
+ },
78
+ };
79
+ }
80
+
81
+ private getListResourceMeta(serverName: string, uri: string): Record<string, unknown> | undefined {
82
+ const connection = this.manager.getConnection(serverName);
83
+ if (!connection?.resources?.length) return undefined;
84
+ const resource = connection.resources.find((entry) => entry.uri === uri);
85
+ if (!resource || !resource._meta || typeof resource._meta !== "object") return undefined;
86
+ return resource._meta;
87
+ }
88
+ }
89
+
90
+ function selectContent(result: ReadResourceResult, preferredUri: string): ResourceContentRecord {
91
+ const contents = (result.contents ?? []) as ResourceContentRecord[];
92
+ if (contents.length === 0) {
93
+ throw new Error(`No contents returned for UI resource: ${preferredUri}`);
94
+ }
95
+
96
+ const byUri = contents.find((content) => content.uri === preferredUri);
97
+ if (byUri) return byUri;
98
+
99
+ const byHtmlMime = contents.find(
100
+ (content) => content.mimeType && isHtmlMimeType(content.mimeType)
101
+ );
102
+ if (byHtmlMime) return byHtmlMime;
103
+
104
+ return contents[0];
105
+ }
106
+
107
+ function isHtmlMimeType(mimeType: string): boolean {
108
+ const normalized = mimeType.toLowerCase();
109
+ return normalized.startsWith("text/html") || normalized === RESOURCE_MIME_TYPE.toLowerCase();
110
+ }
111
+
112
+ function toHtml(content: ResourceContentRecord): string {
113
+ if (typeof content.text === "string") {
114
+ return content.text;
115
+ }
116
+
117
+ if (typeof content.blob === "string") {
118
+ return Buffer.from(content.blob, "base64").toString("utf-8");
119
+ }
120
+
121
+ throw new Error(`UI resource ${content.uri ?? "(unknown)"} did not include text or blob content`);
122
+ }
123
+
124
+ function extractUiMeta(meta: Record<string, unknown> | undefined): UiResourceMeta {
125
+ if (!meta || typeof meta !== "object") return {};
126
+ const ui = meta.ui as Record<string, unknown> | undefined;
127
+ if (!ui || typeof ui !== "object") return {};
128
+
129
+ const out: UiResourceMeta = {};
130
+
131
+ if (ui.csp && typeof ui.csp === "object") {
132
+ out.csp = ui.csp as UiResourceMeta["csp"];
133
+ }
134
+ if (ui.permissions && typeof ui.permissions === "object") {
135
+ out.permissions = ui.permissions as UiResourceMeta["permissions"];
136
+ }
137
+ if (typeof ui.domain === "string") {
138
+ out.domain = ui.domain;
139
+ }
140
+ if (typeof ui.prefersBorder === "boolean") {
141
+ out.prefersBorder = ui.prefersBorder;
142
+ }
143
+
144
+ return out;
145
+ }