Skip to content

Commit fa46c09

Browse files
committed
feat(gpt-apps): add tools and widget descriptors
1 parent c1c415f commit fa46c09

20 files changed

Lines changed: 931 additions & 43 deletions

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,39 @@ As above, this exposes only the specified Actor (`apify/my-actor`) as a tool. No
243243
>
244244
> **For production use and stable interfaces, always explicitly specify the `tools` parameter** to ensure your configuration remains consistent across updates.
245245
246+
### UI mode configuration
247+
248+
The `uiMode` parameter enables OpenAI-specific widget rendering in tool responses. When enabled, tools like `search-actors` return interactive widget responses optimized for OpenAI clients.
249+
250+
**Configuring the hosted server:**
251+
252+
Enable UI mode using the `ui` query parameter:
253+
254+
```
255+
https://mcp.apify.com?ui=openai
256+
```
257+
258+
You can combine it with other parameters:
259+
260+
```
261+
https://mcp.apify.com?tools=actors,docs&ui=openai
262+
```
263+
264+
**Configuring the CLI:**
265+
266+
The CLI can be configured using command-line flags. For example, to enable UI mode:
267+
268+
```bash
269+
npx @apify/actors-mcp-server --uiMode openai
270+
```
271+
272+
You can also set it via the `UI_MODE` environment variable:
273+
274+
```bash
275+
export UI_MODE=openai
276+
npx @apify/actors-mcp-server
277+
```
278+
246279
### Backward compatibility
247280

248281
The v2 configuration preserves backward compatibility with v1 usage. Notes:

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export default [
2727
'evals/*.ts', // Top-level evaluation scripts
2828
'evals/*.md', // Documentation files
2929
'evals/*.json', // Test case data files
30+
'src/web/**', // Web directory has its own TypeScript project and build system
3031
],
3132
},
3233
// Apply the shared Apify TypeScript ESLint configuration

src/actor/server.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import log from '@apify/log';
1515

1616
import { ApifyClient } from '../apify-client.js';
1717
import { ActorsMcpServer } from '../mcp/server.js';
18-
import type { ApifyRequestParams } from '../types.js';
18+
import type { ApifyRequestParams, UiMode } from '../types.js';
1919
import { parseBooleanFromString } from '../utils/generic.js';
2020
import { getHelpMessage, HEADER_READINESS_PROBE, Routes, TransportType } from './const.js';
2121
import { getActorRunData } from './utils.js';
@@ -90,13 +90,17 @@ export function createExpressApp(
9090
?? parseBooleanFromString(process.env.TELEMETRY_ENABLED)
9191
?? true;
9292

93+
const uiModeParam = urlParams.get('ui') as UiMode | undefined;
94+
const uiMode = uiModeParam ?? process.env.UI_MODE as UiMode | undefined;
95+
9396
const mcpServer = new ActorsMcpServer({
9497
taskStore,
9598
setupSigintHandler: false,
9699
transportType: 'sse',
97100
telemetry: {
98101
enabled: telemetryEnabled,
99102
},
103+
uiMode,
100104
});
101105
const transport = new SSEServerTransport(Routes.MESSAGE, res);
102106

@@ -214,6 +218,9 @@ export function createExpressApp(
214218
?? parseBooleanFromString(process.env.TELEMETRY_ENABLED)
215219
?? true;
216220

221+
const uiModeParam = urlParams.get('ui') as UiMode | undefined;
222+
const uiMode = uiModeParam ?? process.env.UI_MODE as UiMode | undefined;
223+
217224
const mcpServer = new ActorsMcpServer({
218225
taskStore,
219226
setupSigintHandler: false,
@@ -222,6 +229,7 @@ export function createExpressApp(
222229
telemetry: {
223230
enabled: telemetryEnabled,
224231
},
232+
uiMode,
225233
});
226234

227235
// Load MCP server tools

src/const.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export enum HelperTools {
4444
DOCS_SEARCH = 'search-apify-docs',
4545
DOCS_FETCH = 'fetch-apify-docs',
4646
GET_HTML_SKELETON = 'get-html-skeleton',
47+
CALL_ACTOR_WIDGET = 'call-actor-widget',
48+
GET_ACTOR_RUN_STATUS = 'get-actor-run-status',
49+
FETCH_ACTOR_DETAILS_WIDGET = 'fetch-actor-details-widget',
4750
}
4851

4952
export const RAG_WEB_BROWSER = 'apify/rag-web-browser';
@@ -227,4 +230,6 @@ These tools are called **Actors**. They enable you to extract structured data fr
227230
\`${HelperTools.STORE_SEARCH}\` finds robust and reliable Actors for specific websites; ${RAG_WEB_BROWSER} is a general and versatile web scraping tool.
228231
- **Dedicated Actor tools (e.g. ${RAG_WEB_BROWSER}) vs ${HelperTools.ACTOR_CALL}:**
229232
Prefer dedicated tools when available; use \`${HelperTools.ACTOR_CALL}\` only when no specialized tool exists in Apify store.
233+
- **Async vs sync Actor tools (${HelperTools.ACTOR_CALL} vs ${HelperTools.CALL_ACTOR_WIDGET}):**
234+
Default to \`${HelperTools.ACTOR_CALL}\` (synchronous, no widget) when the user asks to “run/call” and does not request background/progress/UI. Use \`${HelperTools.CALL_ACTOR_WIDGET}\` only when the user wants background/progress/UI. After starting an async run and obtaining runId, do NOT start another async run—only poll with \`${HelperTools.GET_ACTOR_RUN_STATUS}\` using that runId.
230235
`;

src/mcp/server.ts

Lines changed: 146 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -431,43 +431,160 @@ export class ActorsMcpServer {
431431

432432
private setupResourceHandlers(): void {
433433
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
434+
const resources = [];
435+
434436
/**
435-
* Return the usage guide resource only if Skyfire mode is enabled. No resources otherwise for normal mode.
437+
* Return the usage guide resource only if Skyfire mode is enabled.
436438
*/
437439
if (this.options.skyfireMode) {
438-
return {
439-
resources: [
440-
{
441-
uri: 'file://readme.md',
442-
name: 'readme',
443-
description: `Apify MCP Server usage guide. Read this to understand how to use the server, especially in Skyfire mode before interacting with it.`,
444-
mimeType: 'text/markdown',
440+
resources.push({
441+
uri: 'file://readme.md',
442+
name: 'readme',
443+
description: `Apify MCP Server usage guide. Read this to understand how to use the server, especially in Skyfire mode before interacting with it.`,
444+
mimeType: 'text/markdown',
445+
});
446+
}
447+
448+
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+
// TODO: replace with real CSP domains
461+
'openai/widgetCSP': {
462+
connect_domains: ['https://api.example.com'],
463+
resource_domains: ['https://persistent.oaistatic.com'],
445464
},
446-
],
447-
};
465+
'openai/widgetDomain': 'https://chatgpt.com',
466+
},
467+
});
468+
469+
resources.push({
470+
uri: 'ui://widget/actor-run.html',
471+
name: 'actor-run-widget',
472+
description: 'Interactive Actor run widget',
473+
mimeType: 'text/html+skybridge',
474+
_meta: {
475+
'openai/outputTemplate': 'ui://widget/actor-run.html',
476+
'openai/widgetAccessible': true,
477+
'openai/resultCanProduceWidget': true,
478+
// TODO: replace with real CSP domains
479+
'openai/widgetCSP': {
480+
connect_domains: ['https://api.example.com'],
481+
resource_domains: ['https://persistent.oaistatic.com'],
482+
},
483+
'openai/widgetDomain': 'https://chatgpt.com',
484+
},
485+
});
448486
}
449-
return { resources: [] };
487+
488+
return { resources };
450489
});
451490

452-
if (this.options.skyfireMode) {
453-
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
454-
const { uri } = request.params;
455-
if (uri === 'file://readme.md') {
491+
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
492+
const { uri } = request.params;
493+
if (this.options.skyfireMode && uri === 'file://readme.md') {
494+
return {
495+
contents: [{
496+
uri: 'file://readme.md',
497+
mimeType: 'text/markdown',
498+
text: SKYFIRE_README_CONTENT,
499+
}],
500+
};
501+
}
502+
503+
if (this.options.uiMode === 'openai' && uri.startsWith('ui://widget/')) {
504+
try {
505+
log.debug('Reading widget files', { uri });
506+
const fs = await import('node:fs');
507+
const path = await import('node:path');
508+
const { fileURLToPath } = await import('node:url');
509+
510+
// Get the directory of this file
511+
const filename = fileURLToPath(import.meta.url);
512+
const dirName = path.dirname(filename);
513+
514+
let widgetJsFilename = '';
515+
let widgetTitle = '';
516+
517+
if (uri === 'ui://widget/search-actors.html') {
518+
widgetJsFilename = 'search-actors-widget.js';
519+
widgetTitle = 'Apify Actor Search';
520+
} else if (uri === 'ui://widget/actor-run.html') {
521+
widgetJsFilename = 'actor-run-widget.js';
522+
widgetTitle = 'Apify Actor Run';
523+
} else {
524+
return {
525+
contents: [{
526+
uri, mimeType: 'text/plain', text: `Widget resource ${uri} not found`,
527+
}],
528+
};
529+
}
530+
531+
const widgetJsPath = path.resolve(dirName, `../web/dist/${widgetJsFilename}`);
532+
533+
log.debug('Reading widget file', { widgetJsPath });
534+
535+
const widgetJs = fs.readFileSync(widgetJsPath, 'utf-8');
536+
537+
const widgetHtml = `<!DOCTYPE html>
538+
<html lang="en">
539+
<head>
540+
<meta charset="UTF-8" />
541+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
542+
<title>${widgetTitle}</title>
543+
</head>
544+
<body>
545+
<div id="root"></div>
546+
<script type="module">${widgetJs}</script>
547+
</body>
548+
</html>`;
549+
456550
return {
457551
contents: [{
458-
uri: 'file://readme.md',
459-
mimeType: 'text/markdown',
460-
text: SKYFIRE_README_CONTENT,
552+
uri,
553+
mimeType: 'text/html+skybridge',
554+
text: widgetHtml,
555+
html: widgetHtml,
556+
_meta: {
557+
'openai/widgetPrefersBorder': true,
558+
'openai/outputTemplate': uri,
559+
'openai/widgetAccessible': true,
560+
'openai/resultCanProduceWidget': true,
561+
// TODO: replace with real CSP domains
562+
'openai/widgetCSP': {
563+
connect_domains: ['https://api.example.com'],
564+
resource_domains: ['https://persistent.oaistatic.com'],
565+
},
566+
'openai/widgetDomain': 'https://chatgpt.com',
567+
},
568+
}],
569+
};
570+
} catch (error) {
571+
const errorMessage = error instanceof Error ? error.message : String(error);
572+
return {
573+
contents: [{
574+
uri,
575+
mimeType: 'text/plain',
576+
text: `Failed to load widget: ${errorMessage}`,
461577
}],
462578
};
463579
}
464-
return {
465-
contents: [{
466-
uri, mimeType: 'text/plain', text: `Resource ${uri} not found`,
467-
}],
468-
};
469-
});
470-
}
580+
}
581+
582+
return {
583+
contents: [{
584+
uri, mimeType: 'text/plain', text: `Resource ${uri} not found`,
585+
}],
586+
};
587+
});
471588

472589
this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
473590
// No resource templates available, return empty response
@@ -616,7 +733,10 @@ export class ActorsMcpServer {
616733
* @returns {object} - The response object containing the tools.
617734
*/
618735
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
619-
const tools = Array.from(this.tools.values()).map((tool) => getToolPublicFieldOnly(tool));
736+
const tools = Array.from(this.tools.values()).map((tool) => getToolPublicFieldOnly(tool, {
737+
uiMode: this.options.uiMode,
738+
filterOpenAiMeta: true,
739+
}));
620740
return { tools };
621741
});
622742

src/stdio.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { DEFAULT_TELEMETRY_ENV, TELEMETRY_ENV } from './const.js';
3333
import { processInput } from './input.js';
3434
import { ActorsMcpServer } from './mcp/server.js';
3535
import { getTelemetryEnv } from './telemetry.js';
36-
import type { ApifyRequestParams, Input, TelemetryEnv, ToolSelector } from './types.js';
36+
import type { ApifyRequestParams, Input, TelemetryEnv, ToolSelector, UiMode } from './types.js';
3737
import { parseCommaSeparatedList } from './utils/generic.js';
3838
import { loadToolsFromInput } from './utils/tools-loader.js';
3939

@@ -53,6 +53,10 @@ type CliArgs = {
5353
telemetryEnabled: boolean;
5454
/** Telemetry environment: 'PROD' or 'DEV' (default: 'PROD', only used when telemetry-enabled is true) */
5555
telemetryEnv: TelemetryEnv;
56+
/** UI mode for tool responses.
57+
* - 'openai': OpenAI specific widget rendering. If not specified, there will be no widget rendering.
58+
*/
59+
uiMode: UiMode;
5660
}
5761

5862
/**
@@ -118,6 +122,14 @@ Default: true (enabled)`,
118122
- 'PROD': Send events to production Segment workspace (default)
119123
- 'DEV': Send events to development Segment workspace
120124
Only used when --telemetry-enabled is true`,
125+
})
126+
.option('uiMode', {
127+
type: 'string',
128+
choices: ['openai'],
129+
default: undefined,
130+
describe: `UI mode for tool responses. Can also be set via UI_MODE environment variable.
131+
- 'openai': OpenAI specific widget rendering
132+
Default: undefined (no widget rendering)`,
121133
})
122134
.help('help')
123135
.alias('h', 'help')
@@ -161,6 +173,7 @@ async function main() {
161173
env: getTelemetryEnv(argv.telemetryEnv),
162174
},
163175
token: apifyToken,
176+
uiMode: argv.uiMode,
164177
});
165178

166179
// Create an Input object from CLI arguments

0 commit comments

Comments
 (0)