Skip to content

Commit a9241e2

Browse files
committed
refactor(gpt-apps): centralize and improve widget setup
1 parent a2339ff commit a9241e2

10 files changed

Lines changed: 333 additions & 159 deletions

File tree

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,14 @@
8787
"lint:fix": "eslint . --fix",
8888
"check": "npm run type-check && npm run lint:fix",
8989
"build": "tsc -b src",
90+
"postbuild": "mkdir -p dist/web && (cp -r src/web/dist dist/web/ 2>/dev/null || :)",
9091
"build:watch": "tsc -b src -w",
92+
"check:widgets": "tsx scripts/check-widgets.ts",
9193
"type-check": "tsc --noEmit",
9294
"test": "npm run test:unit",
9395
"test:unit": "vitest run tests/unit",
9496
"test:integration": "npm run build && vitest run tests/integration",
95-
"clean": "tsc -b src --clean",
97+
"clean": "tsc -b src --clean && rm -rf dist/web",
9698
"evals:create-dataset": "tsx evals/create-dataset.ts",
9799
"evals:run": "tsx evals/run-evaluation.ts",
98100
"evals:workflow": "tsx evals/workflows/run-workflow-evals.ts"

scripts/check-widgets.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/* eslint-disable no-console */
2+
/**
3+
* Build-time check to ensure widget files exist
4+
* This script validates that all registered widgets have corresponding files
5+
*/
6+
7+
import { existsSync } from 'node:fs';
8+
import { dirname, resolve } from 'node:path';
9+
import { fileURLToPath } from 'node:url';
10+
11+
import { WIDGET_REGISTRY } from '../src/utils/widgets.js';
12+
13+
const filename = fileURLToPath(import.meta.url);
14+
const projectRoot = resolve(dirname(filename), '..');
15+
const webDistPath = resolve(projectRoot, 'src/web/dist');
16+
17+
const missingWidgets: string[] = [];
18+
const existingWidgets: string[] = [];
19+
20+
for (const config of Object.values(WIDGET_REGISTRY)) {
21+
const jsPath = resolve(webDistPath, config.jsFilename);
22+
if (existsSync(jsPath)) {
23+
existingWidgets.push(config.name);
24+
} else {
25+
missingWidgets.push(`${config.name} (${config.jsFilename})`);
26+
}
27+
}
28+
29+
if (missingWidgets.length > 0) {
30+
console.error('Missing widget files:');
31+
for (const widget of missingWidgets) {
32+
console.error(` - ${widget}`);
33+
}
34+
console.error(`\nExpected location: ${webDistPath}`);
35+
console.error('\nPlease build the widgets before building the server.');
36+
process.exit(1);
37+
}
38+
39+
if (existingWidgets.length > 0) {
40+
console.log('All widget files found:');
41+
for (const widget of existingWidgets) {
42+
console.log(` - ${widget}`);
43+
}
44+
}
45+
46+
process.exit(0);

src/mcp/server.ts

Lines changed: 81 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ import { getToolStatusFromError } from '../utils/tool-status.js';
7373
import { cloneToolEntry, getToolPublicFieldOnly } from '../utils/tools.js';
7474
import { getUserIdFromTokenCached } from '../utils/userid-cache.js';
7575
import { getPackageVersion } from '../utils/version.js';
76+
import type { AvailableWidget } from '../utils/widgets.js';
77+
import { resolveAvailableWidgets } from '../utils/widgets.js';
7678
import { connectMCPClient } from './client.js';
7779
import { EXTERNAL_TOOL_CALL_TIMEOUT_MSEC, LOG_LEVEL_MAP } from './const.js';
7880
import { isTaskCancelled, processParamsGetTools } from './utils.js';
@@ -95,6 +97,9 @@ export class ActorsMcpServer {
9597
private telemetryEnabled: boolean | null = null;
9698
private telemetryEnv: TelemetryEnv = DEFAULT_TELEMETRY_ENV;
9799

100+
// List of widgets that are ready to be served
101+
private availableWidgets: Map<string, AvailableWidget> = new Map();
102+
98103
constructor(options: ActorsMcpServerOptions = {}) {
99104
this.options = options;
100105

@@ -446,51 +451,18 @@ export class ActorsMcpServer {
446451
}
447452

448453
if (this.options.uiMode === 'openai') {
449-
resources.push({
450-
uri: 'ui://widget/search-actors.html',
451-
name: 'search-actors-widget',
452-
description: 'Interactive Actor search results widget',
453-
mimeType: 'text/html+skybridge',
454-
_meta: {
455-
'openai/outputTemplate': 'ui://widget/search-actors.html',
456-
'openai/toolInvocation/invoking': 'Searching Apify Store...',
457-
'openai/toolInvocation/invoked': 'Found Actors matching your criteria',
458-
'openai/widgetAccessible': true,
459-
'openai/resultCanProduceWidget': true,
460-
'openai/widgetDomain': 'https://apify.com',
461-
'openai/widgetCSP': {
462-
connect_domains: [
463-
'https://api.apify.com',
464-
],
465-
resource_domains: [
466-
'https://mcp.apify.com',
467-
'https://images.apifyusercontent.com',
468-
],
469-
},
470-
},
471-
});
472-
473-
resources.push({
474-
uri: 'ui://widget/actor-run.html',
475-
name: 'actor-run-widget',
476-
description: 'Interactive Actor run widget',
477-
mimeType: 'text/html+skybridge',
478-
_meta: {
479-
'openai/outputTemplate': 'ui://widget/actor-run.html',
480-
'openai/widgetAccessible': true,
481-
'openai/resultCanProduceWidget': true,
482-
'openai/widgetDomain': 'https://apify.com',
483-
'openai/widgetCSP': {
484-
connect_domains: [
485-
'https://api.apify.com',
486-
],
487-
resource_domains: [
488-
'https://mcp.apify.com',
489-
'https://images.apifyusercontent.com',
490-
],
491-
},
492-
},
493-
});
454+
// Only register widgets that are available
455+
for (const widget of this.availableWidgets.values()) {
456+
if (widget.exists) {
457+
resources.push({
458+
uri: widget.uri,
459+
name: widget.name,
460+
description: widget.description,
461+
mimeType: 'text/html+skybridge',
462+
_meta: widget.meta,
463+
});
464+
}
465+
}
494466
}
495467

496468
return { resources };
@@ -509,45 +481,30 @@ export class ActorsMcpServer {
509481
}
510482

511483
if (this.options.uiMode === 'openai' && uri.startsWith('ui://widget/')) {
512-
try {
513-
log.debug('Reading widget files', { uri });
514-
const fs = await import('node:fs');
515-
const path = await import('node:path');
516-
const { fileURLToPath } = await import('node:url');
517-
518-
// Get the directory of this file
519-
const filename = fileURLToPath(import.meta.url);
520-
const dirName = path.dirname(filename);
521-
522-
let widgetJsFilename = '';
523-
let widgetTitle = '';
524-
525-
if (uri === 'ui://widget/search-actors.html') {
526-
widgetJsFilename = 'search-actors-widget.js';
527-
widgetTitle = 'Apify Actor Search';
528-
} else if (uri === 'ui://widget/actor-run.html') {
529-
widgetJsFilename = 'actor-run-widget.js';
530-
widgetTitle = 'Apify Actor Run';
531-
} else {
532-
return {
533-
contents: [{
534-
uri, mimeType: 'text/plain', text: `Widget resource ${uri} not found`,
535-
}],
536-
};
537-
}
484+
const widget = this.availableWidgets.get(uri);
538485

539-
const widgetJsPath = path.resolve(dirName, `../web/dist/${widgetJsFilename}`);
486+
if (!widget || !widget.exists) {
487+
return {
488+
contents: [{
489+
uri,
490+
mimeType: 'text/plain',
491+
text: `Widget ${uri} is not available. ${!widget ? 'Not found in registry.' : `File not found at ${widget.jsPath}`}`,
492+
}],
493+
};
494+
}
540495

541-
log.debug('Reading widget file', { widgetJsPath });
496+
try {
497+
log.debug('Reading widget file', { uri, jsPath: widget.jsPath });
498+
const fs = await import('node:fs');
542499

543-
const widgetJs = fs.readFileSync(widgetJsPath, 'utf-8');
500+
const widgetJs = fs.readFileSync(widget.jsPath, 'utf-8');
544501

545502
const widgetHtml = `<!DOCTYPE html>
546503
<html lang="en">
547504
<head>
548505
<meta charset="UTF-8" />
549506
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
550-
<title>${widgetTitle}</title>
507+
<title>${widget.title}</title>
551508
</head>
552509
<body>
553510
<div id="root"></div>
@@ -561,22 +518,7 @@ export class ActorsMcpServer {
561518
mimeType: 'text/html+skybridge',
562519
text: widgetHtml,
563520
html: widgetHtml,
564-
_meta: {
565-
'openai/widgetPrefersBorder': true,
566-
'openai/outputTemplate': uri,
567-
'openai/widgetAccessible': true,
568-
'openai/resultCanProduceWidget': true,
569-
'openai/widgetDomain': 'https://apify.com',
570-
'openai/widgetCSP': {
571-
connect_domains: [
572-
'https://api.apify.com',
573-
],
574-
resource_domains: [
575-
'https://mcp.apify.com',
576-
'https://images.apifyusercontent.com',
577-
],
578-
},
579-
},
521+
_meta: widget.meta,
580522
}],
581523
};
582524
} catch (error) {
@@ -1281,7 +1223,55 @@ Please verify the tool name and ensure the tool is properly registered.`;
12811223
return { telemetryData, userId };
12821224
}
12831225

1226+
/**
1227+
* Resolves widgets and determines which ones are ready to be served.
1228+
*/
1229+
private async resolveWidgets(): Promise<void> {
1230+
if (this.options.uiMode !== 'openai') {
1231+
return;
1232+
}
1233+
1234+
try {
1235+
const { fileURLToPath } = await import('node:url');
1236+
const path = await import('node:path');
1237+
1238+
const filename = fileURLToPath(import.meta.url);
1239+
const dirName = path.dirname(filename);
1240+
1241+
const resolved = await resolveAvailableWidgets(dirName);
1242+
this.availableWidgets = resolved;
1243+
1244+
const readyWidgets: string[] = [];
1245+
const missingWidgets: string[] = [];
1246+
1247+
for (const [uri, widget] of resolved.entries()) {
1248+
if (widget.exists) {
1249+
readyWidgets.push(widget.name);
1250+
} else {
1251+
missingWidgets.push(widget.name);
1252+
log.softFail(`Widget file not found: ${widget.jsPath} (widget: ${uri})`);
1253+
}
1254+
}
1255+
1256+
if (readyWidgets.length > 0) {
1257+
log.debug('Ready widgets', { widgets: readyWidgets });
1258+
}
1259+
1260+
if (missingWidgets.length > 0) {
1261+
log.softFail('Some widgets are not ready', {
1262+
widgets: missingWidgets,
1263+
note: 'These widgets will not be available. Ensure web/dist files are built and included in deployment.',
1264+
});
1265+
}
1266+
} catch (error) {
1267+
const errorMessage = error instanceof Error ? error.message : String(error);
1268+
log.softFail(`Failed to resolve widgets: ${errorMessage}`);
1269+
// Continue without widgets
1270+
}
1271+
}
1272+
12841273
async connect(transport: Transport): Promise<void> {
1274+
await this.resolveWidgets();
12851275
await this.server.connect(transport);
12861276
}
12871277

src/tools/actor.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { buildMCPResponse } from '../utils/mcp.js';
2929
import type { ProgressTracker } from '../utils/progress.js';
3030
import type { JsonSchemaProperty } from '../utils/schema-generation.js';
3131
import { generateSchemaFromItems } from '../utils/schema-generation.js';
32+
import { getWidgetConfig, WIDGET_URIS } from '../utils/widgets.js';
3233
import { getActorDefinition } from './build.js';
3334
import { actorNameToToolName, buildActorInputSchema, fixedAjvCompile, isActorInfoMcpServer } from './utils.js';
3435

@@ -615,21 +616,10 @@ You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.
615616
};
616617

617618
if (apifyMcpServer.options.uiMode === 'openai') {
619+
const widgetConfig = getWidgetConfig(WIDGET_URIS.ACTOR_RUN);
618620
response._meta = {
619-
'openai/outputTemplate': 'ui://widget/actor-run.html',
620-
'openai/widgetAccessible': true,
621-
'openai/resultCanProduceWidget': true,
621+
...widgetConfig?.meta,
622622
'openai/widgetDescription': `Actor run progress for ${actorName}`,
623-
'openai/widgetDomain': 'https://apify.com',
624-
'openai/widgetCSP': {
625-
connect_domains: [
626-
'https://api.apify.com',
627-
],
628-
resource_domains: [
629-
'https://mcp.apify.com',
630-
'https://images.apifyusercontent.com',
631-
],
632-
},
633623
};
634624
}
635625

src/tools/fetch-actor-details.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { InternalToolArgs, ToolEntry, ToolInputSchema } from '../types.js';
66
import { fetchActorDetails, processActorDetailsForResponse } from '../utils/actor-details.js';
77
import { compileSchema } from '../utils/ajv.js';
88
import { buildMCPResponse } from '../utils/mcp.js';
9+
import { getWidgetConfig, WIDGET_URIS } from '../utils/widgets.js';
910
import { actorDetailsOutputSchema } from './structured-output-schemas.js';
1011

1112
const fetchActorDetailsToolArgsSchema = z.object({
@@ -74,24 +75,13 @@ You can search for available Actors using the tool: ${HelperTools.STORE_SEARCH}.
7475
View the interactive widget below for detailed Actor information.
7576
`];
7677

78+
const widgetConfig = getWidgetConfig(WIDGET_URIS.SEARCH_ACTORS);
7779
return buildMCPResponse({
7880
texts,
7981
structuredContent: widgetStructuredContent,
8082
_meta: {
81-
'openai/outputTemplate': 'ui://widget/search-actors.html',
82-
'openai/widgetAccessible': true,
83-
'openai/resultCanProduceWidget': true,
83+
...widgetConfig?.meta,
8484
'openai/widgetDescription': `Actor details for ${parsed.actor} from Apify Store`,
85-
'openai/widgetDomain': 'https://apify.com',
86-
'openai/widgetCSP': {
87-
connect_domains: [
88-
'https://api.apify.com',
89-
],
90-
resource_domains: [
91-
'https://mcp.apify.com',
92-
'https://images.apifyusercontent.com',
93-
],
94-
},
9585
},
9686
});
9787
}

src/tools/run.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { compileSchema } from '../utils/ajv.js';
99
import { logHttpError } from '../utils/logging.js';
1010
import { buildMCPResponse } from '../utils/mcp.js';
1111
import { generateSchemaFromItems } from '../utils/schema-generation.js';
12+
import { getWidgetConfig, WIDGET_URIS } from '../utils/widgets.js';
1213

1314
const getActorRunArgs = z.object({
1415
runId: z.string()
@@ -110,23 +111,12 @@ USAGE EXAMPLES:
110111
? `Actor run ${parsed.runId} completed successfully with ${structuredContent.dataset.itemCount} items. View details in the widget below.`
111112
: `Actor run ${parsed.runId} status: ${run.status}. View progress in the widget below.`;
112113

114+
const widgetConfig = getWidgetConfig(WIDGET_URIS.ACTOR_RUN);
113115
return buildMCPResponse({
114116
texts: [statusText],
115117
structuredContent,
116118
_meta: {
117-
'openai/outputTemplate': 'ui://widget/actor-run.html',
118-
'openai/widgetAccessible': true,
119-
'openai/resultCanProduceWidget': true,
120-
'openai/widgetDomain': 'https://apify.com',
121-
'openai/widgetCSP': {
122-
connect_domains: [
123-
'https://api.apify.com',
124-
],
125-
resource_domains: [
126-
'https://mcp.apify.com',
127-
'https://images.apifyusercontent.com',
128-
],
129-
},
119+
...widgetConfig?.meta,
130120
},
131121
});
132122
}

0 commit comments

Comments
 (0)