-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
3248 lines (2969 loc) · 91.1 KB
/
server.js
File metadata and controls
3248 lines (2969 loc) · 91.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import express from 'express';
import cors from 'cors';
import { WebSocketServer } from 'ws';
import { randomUUID } from 'node:crypto';
import { AsyncLocalStorage } from 'node:async_hooks';
import { z } from 'zod';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import { appleCrayonColorsHexStrings } from './src/utils/color/color.js';
// Parse command line arguments
function parseCommandLineArgs() {
const args = {};
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i];
if (arg === '--browser-url' || arg === '-u') {
args.browserUrl = process.argv[++i];
} else if (arg.startsWith('--browser-url=')) {
args.browserUrl = arg.split('=')[1];
} else if (arg === '--help' || arg === '-h') {
console.log(`
Usage: node server.js [options]
Options:
--browser-url, -u <url> Browser URL for the 3D app (e.g., https://your-app.netlify.app)
Overrides BROWSER_URL environment variable
--help, -h Show this help message
Environment Variables:
BROWSER_URL Browser URL (used if --browser-url not provided)
MCP_PORT MCP server port (default: 3000)
WS_PORT WebSocket server port (default: 3001)
Configuration Priority:
1. Command line argument (--browser-url)
2. Environment variable (BROWSER_URL)
3. Default (http://localhost:5173)
Examples:
node server.js --browser-url https://my-app.netlify.app
node server.js -u http://localhost:5173
`);
process.exit(0);
}
}
return args;
}
const cliArgs = parseCommandLineArgs();
const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000;
const WS_PORT = process.env.WS_PORT ? parseInt(process.env.WS_PORT, 10) : 3001;
// Browser URL for the 3D app (Netlify deployment)
// Priority: 1) Command line argument (--browser-url), 2) Environment variable (BROWSER_URL),
// 3) Default (localhost)
// For .mcpb packages, configuration comes from manifest.json env defaults
const BROWSER_URL = cliArgs.browserUrl || process.env.BROWSER_URL || 'http://localhost:5173';
/**
* Converts a color input (hex code or Apple crayon color name) to a hex code
* @param {string} colorInput - Either a hex code (e.g., "#ff0000") or an Apple crayon color name (e.g., "maraschino")
* @returns {string|null} Hex color code or null if invalid
*/
function normalizeColorToHex(colorInput) {
if (!colorInput || typeof colorInput !== 'string') {
return null;
}
// Check if it's already a hex code
if (/^#[0-9A-Fa-f]{6}$/.test(colorInput)) {
return colorInput.toLowerCase();
}
// Normalize the input: lowercase, trim, and handle variations
let normalizedName = colorInput.toLowerCase().trim();
// Handle "sea foam" variations (with space, without space, with hyphen)
if (normalizedName === 'seafoam' || normalizedName === 'sea-foam') {
normalizedName = 'sea foam';
}
// Try to find it as an Apple crayon color name
const hexColor = appleCrayonColorsHexStrings.get(normalizedName);
if (hexColor) {
return hexColor.toLowerCase();
}
return null;
}
// Store connected WebSocket clients by session ID
// Map<sessionId, WebSocket>
const wsClients = new Map();
// Store pending state queries for request-response correlation
// Map<requestId, {resolve, reject, timeout}>
const pendingStateQueries = new Map();
// Store cached state per session
// Map<sessionId, {state: object, timestamp: number}>
const sessionStateCache = new Map();
// Default timeout for state queries (2 seconds)
const STATE_QUERY_TIMEOUT = 2000;
// Create WebSocket server for browser communication
const wss = new WebSocketServer({ port: WS_PORT });
// Handle WebSocket server errors (e.g., port already in use)
wss.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
console.error(`\n❌ ERROR: Port ${WS_PORT} is already in use.`);
console.error(` Another instance of the server may be running.`);
console.error(` To fix this:`);
console.error(` 1. Find the process using port ${WS_PORT}: lsof -i :${WS_PORT}`);
console.error(` 2. Kill it: kill <PID>`);
console.error(` 3. Or change WS_PORT in your environment\n`);
process.exit(1);
} else {
console.error('WebSocket server error:', error);
process.exit(1);
}
});
wss.on('listening', () => {
// Use console.error to avoid interfering with MCP protocol on stdout
console.error(`WebSocket server listening on ws://localhost:${WS_PORT}`);
});
wss.on('connection', (ws) => {
console.warn('Browser client connected (waiting for session ID)');
let sessionId = null;
// Handle incoming messages from clients
ws.on('message', (message) => {
try {
const data = JSON.parse(message.toString());
// First message should be session registration
if (data.type === 'registerSession' && data.sessionId) {
sessionId = data.sessionId;
wsClients.set(sessionId, ws);
console.warn(`Browser client registered with session ID: ${sessionId}`);
// Send confirmation
ws.send(JSON.stringify({
type: 'sessionRegistered',
sessionId: sessionId
}));
} else if (sessionId) {
// Handle state response messages
if (data.type === 'stateResponse' && data.requestId) {
const query = pendingStateQueries.get(data.requestId);
if (query) {
clearTimeout(query.timeout);
pendingStateQueries.delete(data.requestId);
query.resolve(data.state);
} else {
console.warn(`Received state response for unknown requestId: ${data.requestId}`);
}
return;
}
// Handle state update messages (push updates)
if (data.type === 'stateUpdate' && data.state) {
sessionStateCache.set(sessionId, {
state: data.state,
timestamp: data.timestamp || Date.now()
});
console.warn(`State cache updated for session ${sessionId}`);
return;
}
// Handle state error messages
if (data.type === 'stateError' && data.requestId) {
const query = pendingStateQueries.get(data.requestId);
if (query) {
clearTimeout(query.timeout);
pendingStateQueries.delete(data.requestId);
query.reject(new Error(data.error || 'State query failed'));
}
return;
}
// Handle other messages (for testing/debugging)
console.warn(`Received command from client (session ${sessionId}):`, data);
// Note: We no longer broadcast client-to-client messages
// If needed, this could route to a specific session
} else {
console.warn('Received message from unregistered client');
ws.send(JSON.stringify({
type: 'error',
message: 'Session not registered. Please send registerSession message first.'
}));
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
});
ws.on('close', () => {
if (sessionId) {
console.warn(`Browser client disconnected (session: ${sessionId})`);
wsClients.delete(sessionId);
// Clear state cache for disconnected session
sessionStateCache.delete(sessionId);
// Reject any pending queries for this session
for (const [requestId, query] of pendingStateQueries.entries()) {
clearTimeout(query.timeout);
pendingStateQueries.delete(requestId);
query.reject(new Error('Browser disconnected'));
}
} else {
console.warn('Browser client disconnected (unregistered)');
}
});
ws.on('error', (error) => {
console.error(`WebSocket error (session: ${sessionId || 'unregistered'}):`, error);
});
});
// Send command to a specific session's browser client
function sendToSession(sessionId, command) {
const ws = wsClients.get(sessionId);
if (ws && ws.readyState === 1) { // WebSocket.OPEN
const message = JSON.stringify(command);
ws.send(message);
return true;
} else {
console.warn(`No active WebSocket connection found for session: ${sessionId}`);
return false;
}
}
// Generate a unique request ID for state queries
function generateRequestId() {
return randomUUID();
}
// Wait for a state response from the browser
// Returns a Promise that resolves with the state or rejects on timeout/error
function waitForStateResponse(requestId, timeout = STATE_QUERY_TIMEOUT) {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
pendingStateQueries.delete(requestId);
reject(new Error('State query timeout'));
}, timeout);
pendingStateQueries.set(requestId, {
resolve,
reject,
timeout: timeoutId
});
});
}
// Query state from browser (with optional force refresh)
async function queryStateFromBrowser(sessionId) {
const requestId = generateRequestId();
// Send request to browser
const sent = sendToSession(sessionId, {
type: 'requestState',
requestId: requestId,
forceRefresh: false
});
if (!sent) {
throw new Error('Browser not connected');
}
// Wait for response
return await waitForStateResponse(requestId);
}
// Get state (always queries browser, cache only as fallback)
async function getState(sessionId) {
let state;
let source;
let wasCached = false;
// Always query browser for current state
try {
state = await queryStateFromBrowser(sessionId);
source = 'fresh';
} catch (error) {
// If query fails, fall back to cache if available (browser may be disconnected)
const cached = sessionStateCache.get(sessionId);
if (cached) {
console.warn(`Browser query failed for session ${sessionId}, returning cached state: ${error.message}`);
state = cached.state;
source = 'cache';
wasCached = true;
} else {
// No cache available, throw error
throw new Error(`Unable to retrieve state: ${error.message}. Browser may be disconnected.`);
}
}
// Return state with metadata
return {
state,
metadata: {
source,
wasCached,
timestamp: new Date().toISOString()
}
};
}
// Format state response with metadata for tool responses
function formatStateResponse(value, propertyName, sessionId, metadata) {
const timestamp = metadata.timestamp;
const source = metadata.source;
const stalenessWarning = metadata.wasCached
? ' (using cached state - browser may be disconnected)'
: '';
return `${propertyName}: ${value} (queried at ${timestamp}, source: ${source}${stalenessWarning})`;
}
// Helper function to query fresh state before relative manipulations
async function queryFreshStateForManipulation(sessionId) {
try {
const { state } = await getState(sessionId);
return state;
} catch (error) {
console.warn(`Failed to query fresh state before manipulation: ${error.message}`);
return null;
}
}
// Request-scoped context for current session ID using AsyncLocalStorage
// This maintains context across async operations
const sessionContext = new AsyncLocalStorage();
// Helper function for tool handlers to route commands to the current request's session
// Note: getCurrentSessionId() is defined later after isStdioMode and STDIO_SESSION_ID are declared
function routeToCurrentSession(command) {
const sessionId = sessionContext.getStore();
if (sessionId) {
console.error(`Routing command to session: ${sessionId}`, command.type);
sendToSession(sessionId, command);
} else if (isStdioMode) {
// In STDIO mode, route to the unique STDIO session ID
if (STDIO_SESSION_ID) {
console.error(`Routing command in STDIO mode to session: ${STDIO_SESSION_ID}`, command.type);
sendToSession(STDIO_SESSION_ID, command);
} else {
console.error('Routing command in STDIO mode - no session ID available, broadcasting to all clients:', command.type);
if (wsClients.size > 0) {
broadcastToClients(command);
} else {
console.error('No WebSocket clients connected. Command not routed:', command.type);
}
}
} else {
console.warn('Tool handler called but no session context available. Command not routed.');
console.warn('Current request session ID:', sessionId);
}
}
// Broadcast command to all connected browser clients (kept for backward compatibility if needed)
function broadcastToClients(command) {
const message = JSON.stringify(command);
wsClients.forEach((client, sessionId) => {
if (client.readyState === 1) { // WebSocket.OPEN
client.send(message);
}
});
}
// Create MCP server
const mcpServer = new McpServer({
name: '3d-model-server',
version: '1.0.0'
});
// Create a list of available Apple crayon color names for the description
const availableColorNames = Array.from(appleCrayonColorsHexStrings.keys()).join(', ');
// Zod schema for color input - accepts hex codes or Apple crayon color names
const colorSchema = z.string().refine(
(val) => {
// Accept hex codes
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
return true;
}
// Accept Apple crayon color names (case-insensitive)
let normalizedName = val.toLowerCase().trim();
// Handle "sea foam" variations
if (normalizedName === 'seafoam' || normalizedName === 'sea-foam') {
normalizedName = 'sea foam';
}
return appleCrayonColorsHexStrings.has(normalizedName);
},
{
message: `Must be a hex color code (e.g., "#ff0000") or an Apple crayon color name. Available colors: ${availableColorNames}`
}
).describe(`Hex color code (e.g., "#ff0000") or Apple crayon color name (e.g., "maraschino", "turquoise", "lemon"). Available colors: ${availableColorNames}`);
// Register tool: change_model_color
mcpServer.registerTool(
'change_model_color',
{
title: 'Change Model Color',
description: 'Change the color of the 3D model in the scene',
inputSchema: {
color: colorSchema
}
},
async ({ color }) => {
const hexColor = normalizeColorToHex(color);
if (!hexColor) {
return {
content: [
{
type: 'text',
text: `Invalid color: ${color}. Please use a hex code (e.g., "#ff0000") or an Apple crayon color name.`
}
],
isError: true
};
}
routeToCurrentSession({
type: 'changeColor',
color: hexColor
});
const displayName = /^#[0-9A-Fa-f]{6}$/.test(color) ? hexColor : `${color} (${hexColor})`;
return {
content: [
{
type: 'text',
text: `Model color changed to ${displayName}`
}
]
};
}
);
// Register tool: change_model_size
mcpServer.registerTool(
'change_model_size',
{
title: 'Change Model Size',
description: 'Change the uniform size of the 3D model',
inputSchema: {
size: z.number().positive().describe('New size value (uniform scaling)')
}
},
async ({ size }) => {
routeToCurrentSession({
type: 'changeSize',
size: size
});
return {
content: [
{
type: 'text',
text: `Model size changed to ${size}`
}
]
};
}
);
// Register tool: scale_model
mcpServer.registerTool(
'scale_model',
{
title: 'Scale Model',
description: 'Scale the 3D model independently in each dimension (x, y, z)',
inputSchema: {
x: z.number().positive().describe('Scale factor for X axis'),
y: z.number().positive().describe('Scale factor for Y axis'),
z: z.number().positive().describe('Scale factor for Z axis')
}
},
async ({ x, y, z }) => {
routeToCurrentSession({
type: 'scaleModel',
x: x,
y: y,
z: z
});
return {
content: [
{
type: 'text',
text: `Model scaled to (${x}, ${y}, ${z})`
}
]
};
}
);
// Register tool: change_background_color
mcpServer.registerTool(
'change_background_color',
{
title: 'Change Background Color',
description: 'Change the background color of the 3D scene',
inputSchema: {
color: colorSchema
}
},
async ({ color }) => {
const hexColor = normalizeColorToHex(color);
if (!hexColor) {
return {
content: [
{
type: 'text',
text: `Invalid color: ${color}. Please use a hex code (e.g., "#000000") or an Apple crayon color name.`
}
],
isError: true
};
}
routeToCurrentSession({
type: 'changeBackgroundColor',
color: hexColor
});
const displayName = /^#[0-9A-Fa-f]{6}$/.test(color) ? hexColor : `${color} (${hexColor})`;
return {
content: [
{
type: 'text',
text: `Background color changed to ${displayName}`
}
]
};
}
);
mcpServer.registerTool(
'get_background_color',
{
title: 'Get Background Color',
description: 'Get the current scene background color as a hex color code (e.g., "#000000"). ' +
'Query this before relative color changes to ensure accuracy. ' +
'For absolute changes, you may use recently queried state from context if no manual interactions occurred.',
inputSchema: {}
},
async () => {
const sessionId = getCurrentSessionId();
if (!sessionId) {
return {
content: [
{
type: 'text',
text: 'Error: No active session found.'
}
],
isError: true
};
}
try {
const { state, metadata } = await getState(sessionId);
const color = state.background || '#000000';
return {
content: [
{
type: 'text',
text: formatStateResponse(color, 'Background color', sessionId, metadata)
}
]
};
} catch (error) {
// If browser disconnected, try to return cached state with warning
const cached = sessionStateCache.get(sessionId);
if (cached && cached.state?.background) {
return {
content: [
{
type: 'text',
text: `Background color: ${cached.state.background} (last known state - browser may be disconnected)`
}
]
};
}
return {
content: [
{
type: 'text',
text: `Error retrieving background color: ${error.message}`
}
],
isError: true
};
}
}
);
// Key light control tools
mcpServer.registerTool(
'set_key_light_intensity',
{
title: 'Set Key Light Intensity',
description: 'Set the intensity of the key light (main light source)',
inputSchema: {
intensity: z.number().nonnegative().describe('Light intensity value (0.0 or higher)')
}
},
async ({ intensity }) => {
routeToCurrentSession({
type: 'setKeyLightIntensity',
intensity: intensity
});
return {
content: [
{
type: 'text',
text: `Key light intensity set to ${intensity}`
}
]
};
}
);
mcpServer.registerTool(
'set_key_light_color',
{
title: 'Set Key Light Color',
description: 'Set the color of the key light',
inputSchema: {
color: colorSchema
}
},
async ({ color }) => {
const hexColor = normalizeColorToHex(color);
if (!hexColor) {
return {
content: [
{
type: 'text',
text: `Invalid color: ${color}. Please use a hex code (e.g., "#ffffff") or an Apple crayon color name.`
}
],
isError: true
};
}
routeToCurrentSession({
type: 'setKeyLightColor',
color: hexColor
});
const displayName = /^#[0-9A-Fa-f]{6}$/.test(color) ? hexColor : `${color} (${hexColor})`;
return {
content: [
{
type: 'text',
text: `Key light color changed to ${displayName}`
}
]
};
}
);
mcpServer.registerTool(
'swing_key_light_up',
{
title: 'Swing Key Light Up',
description: 'Rotate the key light upward in an arc around the center of the model',
inputSchema: {}
},
async () => {
routeToCurrentSession({
type: 'swingKeyLightUp'
});
return {
content: [
{
type: 'text',
text: 'Key light swung up'
}
]
};
}
);
mcpServer.registerTool(
'swing_key_light_down',
{
title: 'Swing Key Light Down',
description: 'Rotate the key light downward in an arc around the center of the model',
inputSchema: {}
},
async () => {
routeToCurrentSession({
type: 'swingKeyLightDown'
});
return {
content: [
{
type: 'text',
text: 'Key light swung down'
}
]
};
}
);
mcpServer.registerTool(
'swing_key_light_left',
{
title: 'Swing Key Light Left',
description: 'Rotate the key light leftward in an arc around the center of the model',
inputSchema: {}
},
async () => {
routeToCurrentSession({
type: 'swingKeyLightLeft'
});
return {
content: [
{
type: 'text',
text: 'Key light swung left'
}
]
};
}
);
mcpServer.registerTool(
'swing_key_light_right',
{
title: 'Swing Key Light Right',
description: 'Rotate the key light rightward in an arc around the center of the model',
inputSchema: {}
},
async () => {
routeToCurrentSession({
type: 'swingKeyLightRight'
});
return {
content: [
{
type: 'text',
text: 'Key light swung right'
}
]
};
}
);
// Fill light control tools
mcpServer.registerTool(
'set_fill_light_intensity',
{
title: 'Set Fill Light Intensity',
description: 'Set the intensity of the fill light (shadow-filling light)',
inputSchema: {
intensity: z.number().nonnegative().describe('Light intensity value (0.0 or higher)')
}
},
async ({ intensity }) => {
routeToCurrentSession({
type: 'setFillLightIntensity',
intensity: intensity
});
return {
content: [
{
type: 'text',
text: `Fill light intensity set to ${intensity}`
}
]
};
}
);
mcpServer.registerTool(
'set_fill_light_color',
{
title: 'Set Fill Light Color',
description: 'Set the color of the fill light',
inputSchema: {
color: colorSchema
}
},
async ({ color }) => {
const hexColor = normalizeColorToHex(color);
if (!hexColor) {
return {
content: [
{
type: 'text',
text: `Invalid color: ${color}. Please use a hex code (e.g., "#ffffff") or an Apple crayon color name.`
}
],
isError: true
};
}
routeToCurrentSession({
type: 'setFillLightColor',
color: hexColor
});
const displayName = /^#[0-9A-Fa-f]{6}$/.test(color) ? hexColor : `${color} (${hexColor})`;
return {
content: [
{
type: 'text',
text: `Fill light color changed to ${displayName}`
}
]
};
}
);
mcpServer.registerTool(
'swing_fill_light_up',
{
title: 'Swing Fill Light Up',
description: 'Rotate the fill light upward in an arc around the center of the model',
inputSchema: {}
},
async () => {
routeToCurrentSession({
type: 'swingFillLightUp'
});
return {
content: [
{
type: 'text',
text: 'Fill light swung up'
}
]
};
}
);
mcpServer.registerTool(
'swing_fill_light_down',
{
title: 'Swing Fill Light Down',
description: 'Rotate the fill light downward in an arc around the center of the model',
inputSchema: {}
},
async () => {
routeToCurrentSession({
type: 'swingFillLightDown'
});
return {
content: [
{
type: 'text',
text: 'Fill light swung down'
}
]
};
}
);
mcpServer.registerTool(
'swing_fill_light_left',
{
title: 'Swing Fill Light Left',
description: 'Rotate the fill light leftward in an arc around the center of the model',
inputSchema: {}
},
async () => {
routeToCurrentSession({
type: 'swingFillLightLeft'
});
return {
content: [
{
type: 'text',
text: 'Fill light swung left'
}
]
};
}
);
mcpServer.registerTool(
'swing_fill_light_right',
{
title: 'Swing Fill Light Right',
description: 'Rotate the fill light rightward in an arc around the center of the model',
inputSchema: {}
},
async () => {
routeToCurrentSession({
type: 'swingFillLightRight'
});
return {
content: [
{
type: 'text',
text: 'Fill light swung right'
}
]
};
}
);
mcpServer.registerTool(
'walk_key_light_in',
{
title: 'Walk Key Light In',
description: 'Move the key light closer to the center of the model along the axis from the model origin',
inputSchema: {}
},
async () => {
routeToCurrentSession({
type: 'walkKeyLightIn'
});
return {
content: [
{
type: 'text',
text: 'Key light walked in'
}
]
};
}
);
mcpServer.registerTool(
'walk_key_light_out',
{
title: 'Walk Key Light Out',
description: 'Move the key light farther from the center of the model along the axis from the model origin',
inputSchema: {}
},
async () => {
routeToCurrentSession({
type: 'walkKeyLightOut'
});
return {
content: [
{
type: 'text',
text: 'Key light walked out'
}
]
};
}
);
mcpServer.registerTool(
'walk_fill_light_in',
{
title: 'Walk Fill Light In',
description: 'Move the fill light closer to the center of the model along the axis from the model origin',
inputSchema: {}
},
async () => {
routeToCurrentSession({
type: 'walkFillLightIn'
});
return {
content: [
{
type: 'text',
text: 'Fill light walked in'
}
]
};
}
);
mcpServer.registerTool(
'walk_fill_light_out',
{