Skip to content

Commit 8f592a3

Browse files
committed
feat(examples): Add widget interaction tools to budget-allocator, shadertoy, wiki-explorer, and threejs
budget-allocator: - get-allocations: Get current budget allocations - set-allocation: Set allocation for a category - set-total-budget: Adjust total budget - set-company-stage: Change stage for benchmarks - get-benchmark-comparison: Compare against benchmarks shadertoy: - set-shader-source: Update shader source code - get-shader-info: Get shader source and compilation status - Sends errors via updateModelContext wiki-explorer: - search-article: Search for Wikipedia articles - get-current-article: Get current article info - highlight-node: Highlight a graph node - get-visible-nodes: List visible nodes threejs: - set-scene-source: Update the Three.js scene source code - get-scene-info: Get current scene state and any errors - Sends syntax errors to model via updateModelContext
1 parent 3e6545a commit 8f592a3

File tree

5 files changed

+225
-28
lines changed

5 files changed

+225
-28
lines changed

examples/budget-allocator-server/src/mcp-app.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -680,12 +680,13 @@ app.registerTool(
680680
"set-allocation",
681681
{
682682
title: "Set Category Allocation",
683-
description:
684-
"Set the allocation percentage for a specific budget category",
683+
description: "Set the allocation percentage for a specific budget category",
685684
inputSchema: z.object({
686685
categoryId: z
687686
.string()
688-
.describe("Category ID (e.g., 'rd', 'sales', 'marketing', 'ops', 'ga')"),
687+
.describe(
688+
"Category ID (e.g., 'rd', 'sales', 'marketing', 'ops', 'ga')",
689+
),
689690
percent: z
690691
.number()
691692
.min(0)
@@ -703,7 +704,9 @@ app.registerTool(
703704
};
704705
}
705706

706-
const category = state.config.categories.find((c) => c.id === args.categoryId);
707+
const category = state.config.categories.find(
708+
(c) => c.id === args.categoryId,
709+
);
707710
if (!category) {
708711
return {
709712
content: [
@@ -858,9 +861,7 @@ app.registerTool(
858861
async () => {
859862
if (!state.config || !state.analytics) {
860863
return {
861-
content: [
862-
{ type: "text" as const, text: "Error: Data not loaded" },
863-
],
864+
content: [{ type: "text" as const, text: "Error: Data not loaded" }],
864865
isError: true,
865866
};
866867
}

examples/shadertoy-server/src/mcp-app.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,29 @@ app.registerTool(
239239
description:
240240
"Update the shader source code. Compiles and runs the new shader immediately.",
241241
inputSchema: z.object({
242-
fragmentShader: z.string().describe("The main fragment shader source code (mainImage function)"),
243-
common: z.string().optional().describe("Common code shared across all shaders"),
244-
bufferA: z.string().optional().describe("Buffer A shader source (for multi-pass rendering)"),
245-
bufferB: z.string().optional().describe("Buffer B shader source (for multi-pass rendering)"),
246-
bufferC: z.string().optional().describe("Buffer C shader source (for multi-pass rendering)"),
247-
bufferD: z.string().optional().describe("Buffer D shader source (for multi-pass rendering)"),
242+
fragmentShader: z
243+
.string()
244+
.describe("The main fragment shader source code (mainImage function)"),
245+
common: z
246+
.string()
247+
.optional()
248+
.describe("Common code shared across all shaders"),
249+
bufferA: z
250+
.string()
251+
.optional()
252+
.describe("Buffer A shader source (for multi-pass rendering)"),
253+
bufferB: z
254+
.string()
255+
.optional()
256+
.describe("Buffer B shader source (for multi-pass rendering)"),
257+
bufferC: z
258+
.string()
259+
.optional()
260+
.describe("Buffer C shader source (for multi-pass rendering)"),
261+
bufferD: z
262+
.string()
263+
.optional()
264+
.describe("Buffer D shader source (for multi-pass rendering)"),
248265
}),
249266
},
250267
async (args) => {
@@ -272,8 +289,7 @@ app.registerTool(
272289
"get-shader-info",
273290
{
274291
title: "Get Shader Info",
275-
description:
276-
"Get the current shader source code and compilation status.",
292+
description: "Get the current shader source code and compilation status.",
277293
},
278294
async () => {
279295
log.info("get-shader-info tool called");

examples/threejs-server/src/mcp-app-wrapper.tsx

Lines changed: 161 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,30 @@
77
import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps";
88
import { useApp } from "@modelcontextprotocol/ext-apps/react";
99
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
10-
import { StrictMode, useState, useCallback, useEffect } from "react";
10+
import { StrictMode, useState, useCallback, useEffect, useRef } from "react";
1111
import { createRoot } from "react-dom/client";
12+
import { z } from "zod";
1213
import ThreeJSApp from "./threejs-app.tsx";
1314
import "./global.css";
1415

1516
// =============================================================================
1617
// Types
1718
// =============================================================================
1819

20+
/**
21+
* Scene state tracked for widget interaction tools.
22+
*/
23+
export interface SceneState {
24+
/** Current Three.js code */
25+
code: string | null;
26+
/** Canvas height */
27+
height: number;
28+
/** Last error message if any */
29+
error: string | null;
30+
/** Whether the scene is currently rendering */
31+
isRendering: boolean;
32+
}
33+
1934
/**
2035
* Props passed to the widget component.
2136
* This interface can be reused for other widgets.
@@ -37,6 +52,96 @@ export interface WidgetProps<TToolInput = Record<string, unknown>> {
3752
openLink: App["openLink"];
3853
/** Send log messages to the host */
3954
sendLog: App["sendLog"];
55+
/** Callback to report scene errors */
56+
onSceneError: (error: string | null) => void;
57+
/** Callback to report scene is rendering */
58+
onSceneRendering: (isRendering: boolean) => void;
59+
}
60+
61+
// =============================================================================
62+
// Widget Interaction Tools
63+
// =============================================================================
64+
65+
/**
66+
* Registers widget interaction tools on the App instance.
67+
* These tools allow the model to interact with the Three.js scene.
68+
*/
69+
function registerWidgetTools(
70+
app: App,
71+
sceneStateRef: React.RefObject<SceneState>,
72+
): void {
73+
// Tool: set-scene-source - Update the scene source/configuration
74+
app.registerTool(
75+
"set-scene-source",
76+
{
77+
title: "Set Scene Source",
78+
description:
79+
"Update the Three.js scene source code. The code will be executed in a sandboxed environment with access to THREE, OrbitControls, EffectComposer, RenderPass, UnrealBloomPass, canvas, width, and height.",
80+
inputSchema: z.object({
81+
code: z.string().describe("JavaScript code to render the 3D scene"),
82+
height: z
83+
.number()
84+
.int()
85+
.positive()
86+
.optional()
87+
.describe("Height in pixels (optional, defaults to current)"),
88+
}),
89+
outputSchema: z.object({
90+
success: z.boolean(),
91+
code: z.string(),
92+
height: z.number(),
93+
}),
94+
},
95+
async (args) => {
96+
// Update scene state
97+
sceneStateRef.current.code = args.code;
98+
if (args.height !== undefined) {
99+
sceneStateRef.current.height = args.height;
100+
}
101+
sceneStateRef.current.error = null;
102+
103+
const result = {
104+
success: true,
105+
code: args.code,
106+
height: sceneStateRef.current.height,
107+
};
108+
109+
return {
110+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
111+
structuredContent: result,
112+
};
113+
},
114+
);
115+
116+
// Tool: get-scene-info - Get current scene state and any errors
117+
app.registerTool(
118+
"get-scene-info",
119+
{
120+
title: "Get Scene Info",
121+
description:
122+
"Get the current Three.js scene state including source code, dimensions, rendering status, and any errors.",
123+
outputSchema: z.object({
124+
code: z.string().nullable(),
125+
height: z.number(),
126+
error: z.string().nullable(),
127+
isRendering: z.boolean(),
128+
}),
129+
},
130+
async () => {
131+
const state = sceneStateRef.current;
132+
const result = {
133+
code: state.code,
134+
height: state.height,
135+
error: state.error,
136+
isRendering: state.isRendering,
137+
};
138+
139+
return {
140+
content: [{ type: "text" as const, text: JSON.stringify(result) }],
141+
structuredContent: result,
142+
};
143+
},
144+
);
40145
}
41146

42147
// =============================================================================
@@ -54,14 +159,38 @@ function McpAppWrapper() {
54159
const [toolResult, setToolResult] = useState<CallToolResult | null>(null);
55160
const [hostContext, setHostContext] = useState<McpUiHostContext | null>(null);
56161

162+
// Scene state for widget interaction tools
163+
const sceneStateRef = useRef<SceneState>({
164+
code: null,
165+
height: 400,
166+
error: null,
167+
isRendering: false,
168+
});
169+
170+
// Reference to app for tools to access updateModelContext
171+
const appRef = useRef<App | null>(null);
172+
57173
const { app, error } = useApp({
58174
appInfo: { name: "Three.js Widget", version: "1.0.0" },
59-
capabilities: {},
175+
capabilities: { tools: {} },
60176
onAppCreated: (app) => {
177+
appRef.current = app;
178+
179+
// Register widget interaction tools before connect()
180+
registerWidgetTools(app, sceneStateRef);
181+
61182
// Complete tool input (streaming finished)
62183
app.ontoolinput = (params) => {
63-
setToolInputs(params.arguments as Record<string, unknown>);
184+
const args = params.arguments as Record<string, unknown>;
185+
setToolInputs(args);
64186
setToolInputsPartial(null);
187+
// Update scene state from tool input
188+
if (typeof args.code === "string") {
189+
sceneStateRef.current.code = args.code;
190+
}
191+
if (typeof args.height === "number") {
192+
sceneStateRef.current.height = args.height;
193+
}
65194
};
66195
// Partial tool input (streaming in progress)
67196
app.ontoolinputpartial = (params) => {
@@ -106,6 +235,33 @@ function McpAppWrapper() {
106235
[app],
107236
);
108237

238+
// Callback for scene to report errors
239+
const onSceneError = useCallback((sceneError: string | null) => {
240+
sceneStateRef.current.error = sceneError;
241+
242+
// Send errors to model context for awareness
243+
if (sceneError && appRef.current) {
244+
appRef.current.updateModelContext({
245+
content: [
246+
{
247+
type: "text" as const,
248+
text: `Three.js Scene Error: ${sceneError}`,
249+
},
250+
],
251+
structuredContent: {
252+
type: "scene_error",
253+
error: sceneError,
254+
timestamp: new Date().toISOString(),
255+
},
256+
});
257+
}
258+
}, []);
259+
260+
// Callback for scene to report rendering state
261+
const onSceneRendering = useCallback((isRendering: boolean) => {
262+
sceneStateRef.current.isRendering = isRendering;
263+
}, []);
264+
109265
if (error) {
110266
return <div className="error">Error: {error.message}</div>;
111267
}
@@ -124,6 +280,8 @@ function McpAppWrapper() {
124280
sendMessage={sendMessage}
125281
openLink={openLink}
126282
sendLog={sendLog}
283+
onSceneError={onSceneError}
284+
onSceneRendering={onSceneRendering}
127285
/>
128286
);
129287
}

examples/threejs-server/src/threejs-app.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ export default function ThreeJSApp({
173173
sendMessage: _sendMessage,
174174
openLink: _openLink,
175175
sendLog: _sendLog,
176+
onSceneError,
177+
onSceneRendering,
176178
}: ThreeJSAppProps) {
177179
const [error, setError] = useState<string | null>(null);
178180
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -195,11 +197,21 @@ export default function ThreeJSApp({
195197
if (!code || !canvasRef.current || !containerRef.current) return;
196198

197199
setError(null);
200+
onSceneError(null);
201+
onSceneRendering(true);
202+
198203
const width = containerRef.current.offsetWidth || 800;
199-
executeThreeCode(code, canvasRef.current, width, height).catch((e) =>
200-
setError(e instanceof Error ? e.message : "Unknown error"),
201-
);
202-
}, [code, height]);
204+
executeThreeCode(code, canvasRef.current, width, height)
205+
.then(() => {
206+
onSceneRendering(true);
207+
})
208+
.catch((e) => {
209+
const errorMessage = e instanceof Error ? e.message : "Unknown error";
210+
setError(errorMessage);
211+
onSceneError(errorMessage);
212+
onSceneRendering(false);
213+
});
214+
}, [code, height, onSceneError, onSceneRendering]);
203215

204216
if (isStreaming || !code) {
205217
return (

examples/wiki-explorer-server/src/mcp-app.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,10 @@ app.registerTool(
420420
const response = result.structuredContent as unknown as ToolResponse;
421421
if (response && response.page) {
422422
initialUrl = response.page.url;
423-
addNode(response.page.url, response.page.title, "default", { x: 0, y: 0 });
423+
addNode(response.page.url, response.page.title, "default", {
424+
x: 0,
425+
y: 0,
426+
});
424427
graph.warmupTicks(100);
425428
handleToolResultData(result);
426429
graph.centerAt(0, 0, 500);
@@ -465,7 +468,9 @@ app.registerTool(
465468

466469
if (!currentUrl) {
467470
return {
468-
content: [{ type: "text" as const, text: "No article is currently selected" }],
471+
content: [
472+
{ type: "text" as const, text: "No article is currently selected" },
473+
],
469474
structuredContent: {
470475
hasSelection: false,
471476
article: null,
@@ -477,7 +482,12 @@ app.registerTool(
477482

478483
if (!node) {
479484
return {
480-
content: [{ type: "text" as const, text: "Selected article not found in graph" }],
485+
content: [
486+
{
487+
type: "text" as const,
488+
text: "Selected article not found in graph",
489+
},
490+
],
481491
structuredContent: {
482492
hasSelection: false,
483493
article: null,
@@ -512,7 +522,8 @@ app.registerTool(
512522
"highlight-node",
513523
{
514524
title: "Highlight Node",
515-
description: "Highlight and center on a specific node in the graph by title or URL",
525+
description:
526+
"Highlight and center on a specific node in the graph by title or URL",
516527
inputSchema: z.object({
517528
identifier: z
518529
.string()
@@ -526,8 +537,7 @@ app.registerTool(
526537
// Find node by title (case-insensitive partial match) or exact URL
527538
const node = graphData.nodes.find(
528539
(n) =>
529-
n.url === identifier ||
530-
n.title.toLowerCase().includes(lowerIdentifier),
540+
n.url === identifier || n.title.toLowerCase().includes(lowerIdentifier),
531541
);
532542

533543
if (!node) {

0 commit comments

Comments
 (0)