Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ server code does not import widgets.

- A widget renders from a **tool's output** — to put data on a widget, change the
corresponding tool's return value, not the widget's props by hand.
- Claude Desktop strips `structuredContent` from the tool-result notification
([ext-apps#696](https://github.com/modelcontextprotocol/ext-apps/issues/696));
`mcp-app-context.tsx` recovers it. Every widget tool must support one recovery path:
mirror `structuredContent` as JSON in `content[0]` (run widgets), or be registered in
the entry's `refetchToolForArgs` (idempotent tools only — never run-starting ones).
- Rendering requires **UI mode**: `?ui=true` on the endpoint or `UI_MODE=true`.
- Editing `src/web/src/widgets/*.tsx` hot-reloads; adding a new widget filename
requires reconnecting the MCP client to pick it up.
Expand Down
60 changes: 59 additions & 1 deletion src/web/src/context/mcp-app-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,37 @@ interface McpAppState {

const McpAppContext = createContext<McpAppState | null>(null);

/**
* Claude Desktop strips `structuredContent` (and `_meta`) from the
* `ui/notifications/tool-result` notification — the widget receives only `content`/`isError`
* (https://github.com/modelcontextprotocol/ext-apps/issues/696). Two recovery paths:
*
* 1. Parse `content[0]` as JSON — the run-widget tools mirror `structuredContent` there,
* so recovery is free and never re-executes anything.
* 2. Re-call the tool via the host's `tools/call` proxy, which returns the full
* `CallToolResult` intact. Only for read-only tools: the widget entry supplies
* `refetchToolForArgs` mapping the captured tool-input args to an idempotent tool name
* (or null to skip). Never used for run-starting tools.
*
* Both paths are no-ops on hosts that deliver `structuredContent` (claude.ai, ChatGPT,
* MCP Jam). When ext-apps#696 is fixed, remove the workaround: grep for "ext-apps#696" —
* this block, the `refetchToolForArgs` wiring in init-widget/entries, and the AGENTS.md note.
*/
export type RefetchToolForArgs = (args: Record<string, unknown>) => string | null;

function parseStructuredContentFromText(result: CallToolResult): Record<string, unknown> | null {
const first = result.content?.[0];
if (first?.type !== 'text') return null;
try {
const parsed: unknown = JSON.parse(first.text);
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}

/**
* Provides a single MCP Apps connection (via `useApp()`) shared across all widget components.
*
Expand All @@ -25,20 +56,47 @@ const McpAppContext = createContext<McpAppState | null>(null);
* (set synchronously by ChatGPT's Apps SDK compatibility layer) as initial data.
* See the `receivedViaBridge` ref and the `useEffect` below.
*/
export function McpAppProvider({ children }: { children: React.ReactNode }) {
export function McpAppProvider({
children,
refetchToolForArgs,
}: {
children: React.ReactNode;
refetchToolForArgs?: RefetchToolForArgs;
}) {
const [toolResult, setToolResult] = useState<CallToolResult | null>(null);
const [hostContext, setHostContext] = useState<McpUiHostContext | undefined>();
const receivedViaBridge = useRef(false);
const lastToolArgs = useRef<Record<string, unknown> | null>(null);

const { app } = useApp({
appInfo: { name: 'Apify MCP Widget', version: '1.0.0' },
capabilities: {},
onAppCreated: (createdApp) => {
createdApp.ontoolresult = (result) => {
receivedViaBridge.current = true;
if (!result.structuredContent && !result.isError) {
// Claude Desktop strips structuredContent from the notification (ext-apps#696).
const parsed = parseStructuredContentFromText(result);
if (parsed) {
setToolResult({ ...result, structuredContent: parsed });
return;
}
const args = lastToolArgs.current;
const refetchTool = args ? (refetchToolForArgs?.(args) ?? null) : null;
if (refetchTool) {
createdApp
.callServerTool({ name: refetchTool, arguments: args ?? {} })
.then((full) => setToolResult(full.structuredContent ? full : result))
.catch(() => setToolResult(result));
return;
}
}
setToolResult(result);
};
createdApp.onhostcontextchanged = (ctx) => setHostContext((prev) => ({ ...prev, ...ctx }));
createdApp.ontoolinput = (params) => {
lastToolArgs.current = params.arguments ?? null;
};
},
});

Expand Down
6 changes: 3 additions & 3 deletions src/web/src/utils/init-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ThemeProvider } from 'styled-components';
import { UiDependencyProvider } from '@apify/ui-library';
import { cssColorsVariablesLight, cssColorsVariablesDark } from '@apify/ui-library';

import { McpAppProvider, useMcpApp } from '../context/mcp-app-context';
import { McpAppProvider, useMcpApp, type RefetchToolForArgs } from '../context/mcp-app-context';

function applyDocumentTheme(theme: McpUiTheme): void {
document.documentElement.setAttribute('data-theme', theme);
Expand Down Expand Up @@ -145,7 +145,7 @@ function injectStylesheets(): void {
});
}

export const renderWidget = (Component: React.FC) => {
export const renderWidget = (Component: React.FC, options?: { refetchToolForArgs?: RefetchToolForArgs }) => {
const initWidget = () => {
const rootElement = document.getElementById('root');
if (!rootElement) return;
Expand Down Expand Up @@ -188,7 +188,7 @@ export const renderWidget = (Component: React.FC) => {
root.render(
<ThemeProvider theme={{}}>
<UiDependencyProvider dependencies={dependencies as any}>
<McpAppProvider>
<McpAppProvider refetchToolForArgs={options?.refetchToolForArgs}>
<ThemeSync />
<Component />
</McpAppProvider>
Expand Down
6 changes: 5 additions & 1 deletion src/web/src/widgets/search-actors-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@ import { renderWidget } from '../utils/init-widget';
const { setupSearchActorsWidgetDev } = await import('./search-actors-widget.dev');
setupSearchActorsWidgetDev();
}
renderWidget(ActorSearch);
// ext-apps#696 workaround: both tools rendering this widget are read-only, so a
// Desktop-stripped result can be recovered by re-calling the tool through the host proxy.
renderWidget(ActorSearch, {
refetchToolForArgs: (args) => ('actor' in args ? 'fetch-actor-details-widget' : 'search-actors-widget'),
});
})();
Loading