@shirbarzur/planform-mcp-server 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts ADDED
@@ -0,0 +1,1189 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ Tool,
7
+ CallToolResult,
8
+ ListToolsResult,
9
+ } from '@modelcontextprotocol/sdk/types.js';
10
+ import { ApiClient } from './api-client.js';
11
+ import { Logger } from './logger.js';
12
+ import { USAGE_EXAMPLES } from './usage-examples.js';
13
+ import open from 'open';
14
+
15
+ export class PlanformMCPServer {
16
+ private server: Server;
17
+ private apiClient: ApiClient;
18
+ private logger: Logger;
19
+ private activePolling: Map<string, NodeJS.Timeout> = new Map();
20
+
21
+ constructor() {
22
+ this.server = new Server(
23
+ {
24
+ name: process.env.MCP_SERVER_NAME || 'planform-mcp',
25
+ version: process.env.MCP_SERVER_VERSION || '1.0.0',
26
+ },
27
+ {
28
+ capabilities: {
29
+ tools: {},
30
+ },
31
+ instructions: USAGE_EXAMPLES.trim(),
32
+ }
33
+ );
34
+
35
+ this.apiClient = new ApiClient();
36
+ this.logger = new Logger();
37
+
38
+ this.setupHandlers();
39
+ }
40
+
41
+ private setupHandlers() {
42
+ // List available tools
43
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => {
44
+ this.logger.info('Listing available tools');
45
+
46
+ const tools: Tool[] = [
47
+ {
48
+ name: 'health_check',
49
+ description: 'Check the health status of the Planform MCP server',
50
+ inputSchema: {
51
+ type: 'object',
52
+ properties: {},
53
+ },
54
+ },
55
+ {
56
+ name: 'get_usage_examples',
57
+ description: 'Get usage examples and step-by-step instructions for the Planform MCP (auth flow, list diagrams, create diagram, etc.). Call this when unsure how to use the server.',
58
+ inputSchema: {
59
+ type: 'object',
60
+ properties: {},
61
+ },
62
+ },
63
+ {
64
+ name: 'device_start',
65
+ description: 'REQUIRED FIRST before listing or managing diagrams. Start device code flow: opens browser for user to approve; after approval returns approved_user_uuid and stores the token. Use approved_user_uuid from the response as user_uuid for diagrams_list, diagram_create, etc.',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {},
69
+ },
70
+ },
71
+ {
72
+ name: 'device_token_poll',
73
+ description: '[Optional/Advanced] Manually poll for MCP JWT after user approves. Usually not needed since device_start polls automatically.',
74
+ inputSchema: {
75
+ type: 'object',
76
+ properties: {
77
+ device_code: {
78
+ type: 'string',
79
+ description: 'Device code from device_start response',
80
+ },
81
+ },
82
+ required: ['device_code'],
83
+ },
84
+ },
85
+ {
86
+ name: 'backend_health_check',
87
+ description: 'Check the health status of the Planform backend API',
88
+ inputSchema: {
89
+ type: 'object',
90
+ properties: {},
91
+ },
92
+ },
93
+ {
94
+ name: 'diagram_create',
95
+ description: 'Create a new diagram. Requires prior authentication via device_start; use approved_user_uuid from device_start response as user_uuid.',
96
+ inputSchema: {
97
+ type: 'object',
98
+ properties: {
99
+ user_uuid: {
100
+ type: 'string',
101
+ description: 'User UUID who owns the diagram (use approved_user_uuid from device_start response)',
102
+ },
103
+ title: {
104
+ type: 'string',
105
+ description: 'Diagram title',
106
+ },
107
+ type: {
108
+ type: 'string',
109
+ description: 'Diagram type (e.g., "uml.class")',
110
+ },
111
+ },
112
+ required: ['user_uuid', 'title', 'type'],
113
+ },
114
+ },
115
+ {
116
+ name: 'diagrams_list',
117
+ description: 'List all diagrams of the authenticated user. REQUIRES authentication first: call device_start, then use the approved_user_uuid from that response as user_uuid here.',
118
+ inputSchema: {
119
+ type: 'object',
120
+ properties: {
121
+ user_uuid: {
122
+ type: 'string',
123
+ description: 'User UUID (use approved_user_uuid from device_start response after authenticating)',
124
+ },
125
+ status: {
126
+ type: 'string',
127
+ description: 'Filter by status (active|archived)',
128
+ enum: ['active', 'archived'],
129
+ },
130
+ page: {
131
+ type: 'number',
132
+ description: 'Page number (default: 1)',
133
+ default: 1,
134
+ },
135
+ page_size: {
136
+ type: 'number',
137
+ description: 'Number of items per page (default: 10)',
138
+ default: 10,
139
+ },
140
+ },
141
+ required: ['user_uuid'],
142
+ },
143
+ },
144
+ {
145
+ name: 'diagram_get',
146
+ description: 'Fetch full diagram (nodes + links) to mirror state locally. Requires prior authentication via device_start.',
147
+ inputSchema: {
148
+ type: 'object',
149
+ properties: {
150
+ diagram_uuid: {
151
+ type: 'string',
152
+ description: 'Diagram UUID',
153
+ },
154
+ },
155
+ required: ['diagram_uuid'],
156
+ },
157
+ },
158
+ {
159
+ name: 'nodes_create',
160
+ description: 'Create UML node with immutable grid placement',
161
+ inputSchema: {
162
+ type: 'object',
163
+ properties: {
164
+ diagram_uuid: {
165
+ type: 'string',
166
+ description: 'Diagram UUID',
167
+ },
168
+ kind: {
169
+ type: 'string',
170
+ description: 'Node kind',
171
+ enum: ['class', 'interface', 'enum'],
172
+ },
173
+ name: {
174
+ type: 'string',
175
+ description: 'Node name',
176
+ },
177
+ fields: {
178
+ type: 'array',
179
+ description: 'Node fields',
180
+ items: {
181
+ type: 'object',
182
+ properties: {
183
+ name: { type: 'string' },
184
+ type: { type: 'string' },
185
+ visibility: { type: 'string' },
186
+ static: { type: 'boolean' },
187
+ },
188
+ },
189
+ },
190
+ methods: {
191
+ type: 'array',
192
+ description: 'Node methods',
193
+ items: {
194
+ type: 'object',
195
+ properties: {
196
+ name: { type: 'string' },
197
+ returns: { type: 'string' },
198
+ visibility: { type: 'string' },
199
+ static: { type: 'boolean' },
200
+ params: {
201
+ type: 'array',
202
+ items: {
203
+ type: 'object',
204
+ properties: {
205
+ name: { type: 'string' },
206
+ type: { type: 'string' },
207
+ },
208
+ },
209
+ },
210
+ },
211
+ },
212
+ },
213
+ enumValues: {
214
+ type: 'array',
215
+ description: 'Enum values (for enum nodes)',
216
+ items: {
217
+ type: 'object',
218
+ properties: {
219
+ name: { type: 'string' },
220
+ value: { type: ['string', 'null'] },
221
+ documentation: { type: ['string', 'null'] },
222
+ },
223
+ required: ['name'],
224
+ },
225
+ },
226
+ stereotypes: {
227
+ type: 'array',
228
+ description: 'Node stereotypes',
229
+ items: { type: 'string' },
230
+ },
231
+ },
232
+ required: ['diagram_uuid', 'kind'],
233
+ },
234
+ },
235
+ {
236
+ name: 'node_update',
237
+ description: 'Update structural fields (MCP authoritative)',
238
+ inputSchema: {
239
+ type: 'object',
240
+ properties: {
241
+ diagram_uuid: {
242
+ type: 'string',
243
+ description: 'Diagram UUID',
244
+ },
245
+ node_id: {
246
+ type: 'string',
247
+ description: 'Node ID',
248
+ },
249
+ patch: {
250
+ type: 'object',
251
+ description: 'Node fields to update',
252
+ },
253
+ if_version: {
254
+ type: 'number',
255
+ description: 'Version for optimistic concurrency',
256
+ },
257
+ },
258
+ required: ['diagram_uuid', 'node_id', 'patch'],
259
+ },
260
+ },
261
+ {
262
+ name: 'node_verify',
263
+ description: 'Mark a node as verified by MCP after confirming structure in code',
264
+ inputSchema: {
265
+ type: 'object',
266
+ properties: {
267
+ diagram_uuid: {
268
+ type: 'string',
269
+ description: 'Diagram UUID',
270
+ },
271
+ node_id: {
272
+ type: 'string',
273
+ description: 'Node ID',
274
+ },
275
+ structural_fields: {
276
+ type: 'object',
277
+ description: 'Structural fields to verify',
278
+ },
279
+ diff_detected: {
280
+ type: 'boolean',
281
+ description: 'Whether differences were detected',
282
+ },
283
+ },
284
+ required: ['diagram_uuid', 'node_id', 'structural_fields'],
285
+ },
286
+ },
287
+ {
288
+ name: 'node_delete',
289
+ description: 'Delete node with status-based logic',
290
+ inputSchema: {
291
+ type: 'object',
292
+ properties: {
293
+ diagram_uuid: {
294
+ type: 'string',
295
+ description: 'Diagram UUID',
296
+ },
297
+ node_id: {
298
+ type: 'string',
299
+ description: 'Node ID',
300
+ },
301
+ },
302
+ required: ['diagram_uuid', 'node_id'],
303
+ },
304
+ },
305
+ {
306
+ name: 'links_create',
307
+ description: 'Create link between nodes (never moves nodes). Note: from and to must be node labels (e.g. n-1, n-2), not UUIDs; using UUIDs returns 400.',
308
+ inputSchema: {
309
+ type: 'object',
310
+ properties: {
311
+ diagram_uuid: {
312
+ type: 'string',
313
+ description: 'Diagram UUID',
314
+ },
315
+ kind: {
316
+ type: 'string',
317
+ description: 'Link kind',
318
+ enum: ['association', 'aggregation', 'composition', 'inheritance', 'implements', 'dependency'],
319
+ },
320
+ from: {
321
+ type: 'string',
322
+ description: 'Source node label (e.g. n-1, n-2). Backend expects labels, not node UUIDs.',
323
+ },
324
+ to: {
325
+ type: 'string',
326
+ description: 'Target node label (e.g. n-1, n-2). Backend expects labels, not node UUIDs.',
327
+ },
328
+ label: {
329
+ type: 'string',
330
+ description: 'Link label',
331
+ },
332
+ directional: {
333
+ type: 'string',
334
+ description: 'Link directionality: "none", "unidirectional", or "bidirectional". For inheritance/implements/dependency links, must be "unidirectional" or "bidirectional" (not "none"). If omitted, backend will set appropriate defaults based on link kind.',
335
+ enum: ['none', 'unidirectional', 'bidirectional'],
336
+ },
337
+ },
338
+ required: ['diagram_uuid', 'kind', 'from', 'to'],
339
+ },
340
+ },
341
+ {
342
+ name: 'link_update',
343
+ description: 'Update link fields (MCP authoritative)',
344
+ inputSchema: {
345
+ type: 'object',
346
+ properties: {
347
+ diagram_uuid: {
348
+ type: 'string',
349
+ description: 'Diagram UUID',
350
+ },
351
+ link_id: {
352
+ type: 'string',
353
+ description: 'Link ID',
354
+ },
355
+ patch: {
356
+ type: 'object',
357
+ description: 'Link fields to update',
358
+ },
359
+ if_version: {
360
+ type: 'number',
361
+ description: 'Version for optimistic concurrency',
362
+ },
363
+ },
364
+ required: ['diagram_uuid', 'link_id', 'patch'],
365
+ },
366
+ },
367
+ {
368
+ name: 'link_verify',
369
+ description: 'Mark a link as verified by MCP after confirming relationship in code',
370
+ inputSchema: {
371
+ type: 'object',
372
+ properties: {
373
+ diagram_uuid: {
374
+ type: 'string',
375
+ description: 'Diagram UUID',
376
+ },
377
+ link_id: {
378
+ type: 'string',
379
+ description: 'Link ID',
380
+ },
381
+ structural_fields: {
382
+ type: 'object',
383
+ description: 'Structural fields to verify',
384
+ },
385
+ diff_detected: {
386
+ type: 'boolean',
387
+ description: 'Whether differences were detected',
388
+ },
389
+ },
390
+ required: ['diagram_uuid', 'link_id', 'structural_fields'],
391
+ },
392
+ },
393
+ {
394
+ name: 'link_delete',
395
+ description: 'Delete link with status-based logic',
396
+ inputSchema: {
397
+ type: 'object',
398
+ properties: {
399
+ diagram_uuid: {
400
+ type: 'string',
401
+ description: 'Diagram UUID',
402
+ },
403
+ link_id: {
404
+ type: 'string',
405
+ description: 'Link ID',
406
+ },
407
+ },
408
+ required: ['diagram_uuid', 'link_id'],
409
+ },
410
+ },
411
+ ];
412
+
413
+ return { tools };
414
+ });
415
+
416
+ // Handle tool calls
417
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
418
+ this.logger.info(`Tool called: ${request.params.name}`);
419
+
420
+ try {
421
+ switch (request.params.name) {
422
+ case 'health_check':
423
+ return await this.handleHealthCheck();
424
+
425
+ case 'get_usage_examples':
426
+ return await this.handleGetUsageExamples();
427
+
428
+ case 'device_start':
429
+ return await this.handleDeviceStart();
430
+
431
+ case 'device_token_poll':
432
+ return await this.handleDeviceTokenPoll(request.params.arguments);
433
+
434
+ case 'backend_health_check':
435
+ return await this.handleBackendHealthCheck();
436
+
437
+ case 'diagram_create':
438
+ return await this.handleDiagramCreate(request.params.arguments);
439
+
440
+ case 'diagram_get':
441
+ return await this.handleDiagramGet(request.params.arguments);
442
+
443
+ case 'diagrams_list':
444
+ return await this.handleDiagramsList(request.params.arguments);
445
+
446
+ case 'nodes_create':
447
+ return await this.handleNodesCreate(request.params.arguments);
448
+
449
+ case 'node_update':
450
+ return await this.handleNodeUpdate(request.params.arguments);
451
+
452
+ case 'node_verify':
453
+ return await this.handleNodeVerify(request.params.arguments);
454
+
455
+ case 'node_delete':
456
+ return await this.handleNodeDelete(request.params.arguments);
457
+
458
+ case 'links_create':
459
+ return await this.handleLinksCreate(request.params.arguments);
460
+
461
+ case 'link_update':
462
+ return await this.handleLinkUpdate(request.params.arguments);
463
+
464
+ case 'link_verify':
465
+ return await this.handleLinkVerify(request.params.arguments);
466
+
467
+ case 'link_delete':
468
+ return await this.handleLinkDelete(request.params.arguments);
469
+
470
+ default:
471
+ throw new Error(`Unknown tool: ${request.params.name}`);
472
+ }
473
+ } catch (error) {
474
+ this.logger.error(`Error in tool ${request.params.name}:`, error);
475
+ return {
476
+ content: [
477
+ {
478
+ type: 'text',
479
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
480
+ },
481
+ ],
482
+ isError: true,
483
+ };
484
+ }
485
+ });
486
+ }
487
+
488
+ private async handleHealthCheck(): Promise<CallToolResult> {
489
+ this.logger.info('Health check requested');
490
+
491
+ return {
492
+ content: [
493
+ {
494
+ type: 'text',
495
+ text: JSON.stringify({
496
+ status: 'healthy',
497
+ server: 'planform-mcp',
498
+ version: process.env.MCP_SERVER_VERSION || '1.0.0',
499
+ timestamp: new Date().toISOString(),
500
+ }, null, 2),
501
+ },
502
+ ],
503
+ isError: false,
504
+ };
505
+ }
506
+
507
+ private async handleGetUsageExamples(): Promise<CallToolResult> {
508
+ this.logger.info('Usage examples requested');
509
+ return {
510
+ content: [
511
+ {
512
+ type: 'text',
513
+ text: USAGE_EXAMPLES.trim(),
514
+ },
515
+ ],
516
+ isError: false,
517
+ };
518
+ }
519
+
520
+ private async handleDeviceStart(): Promise<CallToolResult> {
521
+ this.logger.info('Device start requested');
522
+
523
+ try {
524
+ const response = await this.apiClient.startDeviceFlow();
525
+
526
+ // Override verification URI if FRONTEND_BASE_URL is set
527
+ let verificationUri = response.verificationUri;
528
+ let verificationUriComplete = response.verificationUriComplete;
529
+
530
+ const frontendBaseUrl = process.env.FRONTEND_BASE_URL;
531
+ if (frontendBaseUrl) {
532
+ // Parse the original URI to extract the path
533
+ try {
534
+ const originalUrl = new URL(response.verificationUri);
535
+ const path = originalUrl.pathname + originalUrl.search;
536
+
537
+ // Replace with the configured frontend base URL
538
+ const newBaseUrl = frontendBaseUrl.endsWith('/')
539
+ ? frontendBaseUrl.slice(0, -1)
540
+ : frontendBaseUrl;
541
+ verificationUri = `${newBaseUrl}${path}`;
542
+
543
+ // Update verification_uri_complete with the user code
544
+ const userCode = response.session.userCode;
545
+ const separator = path.includes('?') ? '&' : '?';
546
+ verificationUriComplete = `${verificationUri}${separator}code=${userCode}`;
547
+
548
+ this.logger.info(`Overriding verification URI: ${response.verificationUri} -> ${verificationUri}`);
549
+ } catch (urlError) {
550
+ this.logger.warn(`Failed to parse verification URI, using original: ${urlError}`);
551
+ }
552
+ }
553
+
554
+ // Automatically open the browser with the verification URI
555
+ try {
556
+ await open(verificationUriComplete);
557
+ this.logger.info(`Opened browser with verification URI: ${verificationUriComplete}`);
558
+ } catch (openError) {
559
+ this.logger.warn(`Failed to open browser automatically: ${openError}. Please open ${verificationUriComplete} manually.`);
560
+ }
561
+
562
+ // Poll synchronously until token is received or expired
563
+ const maxWaitSeconds = parseInt(process.env.MCP_AUTH_MAX_WAIT_SECONDS || '120', 10);
564
+ const actualWaitTime = Math.min(response.expiresIn, maxWaitSeconds);
565
+ this.logger.info(`Waiting for user approval (polling every ${response.interval}s, will wait up to ${actualWaitTime}s of ${response.expiresIn}s total)...`);
566
+ const pollResult = await this.pollUntilApproved(
567
+ response.session.deviceCode,
568
+ response.expiresIn,
569
+ response.interval
570
+ );
571
+
572
+ // Extract the relevant information for the MCP client
573
+ const deviceInfo: any = {
574
+ // Session fields from MCPDeviceSessionDTO
575
+ uuid: response.session.uuid,
576
+ created_at: response.session.createdAt,
577
+ updated_at: response.session.updatedAt,
578
+ device_code: response.session.deviceCode,
579
+ user_code: response.session.userCode,
580
+ state: response.session.state,
581
+ approved_user_uuid: response.session.approvedUserUuid,
582
+ expires_at: response.session.expiresAt,
583
+ approved_at: response.session.approvedAt,
584
+ // Additional device flow fields
585
+ verification_uri: verificationUri,
586
+ verification_uri_complete: verificationUriComplete,
587
+ expires_in: response.expiresIn,
588
+ interval: response.interval,
589
+ };
590
+
591
+ if (pollResult.success) {
592
+ deviceInfo.message = '✅ Authentication successful! Token received and stored.';
593
+ deviceInfo.access_token_received = true;
594
+ } else {
595
+ deviceInfo.message = `❌ Authentication failed: ${pollResult.error || 'Unknown error'}`;
596
+ deviceInfo.access_token_received = false;
597
+ }
598
+
599
+ return {
600
+ content: [
601
+ {
602
+ type: 'text',
603
+ text: JSON.stringify(deviceInfo, null, 2),
604
+ },
605
+ ],
606
+ isError: false,
607
+ };
608
+ } catch (error) {
609
+ this.logger.error('Device start failed:', error);
610
+ throw error;
611
+ }
612
+ }
613
+
614
+ /**
615
+ * Poll synchronously until token is received, expired, or denied
616
+ * Returns a promise that resolves when polling completes
617
+ */
618
+ private async pollUntilApproved(
619
+ deviceCode: string,
620
+ expiresIn: number,
621
+ interval: number
622
+ ): Promise<{ success: boolean; error?: string }> {
623
+ const startTime = Date.now();
624
+
625
+ // Use a shorter timeout for synchronous waiting (default 2 minutes)
626
+ // This prevents Cursor from waiting the full 10 minutes
627
+ const maxWaitSeconds = parseInt(process.env.MCP_AUTH_MAX_WAIT_SECONDS || '120', 10);
628
+ const actualExpireTime = Math.min(
629
+ startTime + (expiresIn * 1000), // Backend expiration
630
+ startTime + (maxWaitSeconds * 1000) // Configurable max wait
631
+ );
632
+
633
+ let pollCount = 0;
634
+
635
+ while (true) {
636
+ const now = Date.now();
637
+ pollCount++;
638
+
639
+ // Check if expired or max wait time reached
640
+ if (now >= actualExpireTime) {
641
+ const elapsed = Math.floor((now - startTime) / 1000);
642
+ if (elapsed >= maxWaitSeconds && elapsed < expiresIn) {
643
+ this.logger.warn(`Max wait time (${maxWaitSeconds}s) reached. Device code still valid for ${expiresIn - elapsed}s.`);
644
+ return {
645
+ success: false,
646
+ error: `Max wait time (${maxWaitSeconds}s) reached. Please approve in browser and use device_token_poll manually (optional), or try device_start again.`
647
+ };
648
+ } else {
649
+ this.logger.warn('Device code expired while waiting for approval');
650
+ return { success: false, error: 'Device code expired' };
651
+ }
652
+ }
653
+
654
+ try {
655
+ this.logger.info(`Polling for token (attempt ${pollCount})...`);
656
+ const response = await this.apiClient.pollDeviceToken(deviceCode);
657
+
658
+ if (response.access_token) {
659
+ // Success! Token received
660
+ this.logger.info('✅ Device token received successfully!');
661
+ return { success: true };
662
+ } else if (response.error) {
663
+ if (response.error === 'authorization_pending') {
664
+ // Continue polling
665
+ this.logger.info(`⏳ Authorization still pending (attempt ${pollCount}), waiting ${interval}s before next poll...`);
666
+ await new Promise(resolve => setTimeout(resolve, interval * 1000));
667
+ continue;
668
+ } else if (response.error === 'access_denied') {
669
+ this.logger.warn('❌ User denied access');
670
+ return { success: false, error: 'User denied access' };
671
+ } else if (response.error === 'expired_token') {
672
+ this.logger.warn('❌ Device code expired');
673
+ return { success: false, error: 'Device code expired' };
674
+ } else {
675
+ this.logger.warn(`Unexpected error: ${response.error}`);
676
+ return { success: false, error: response.error };
677
+ }
678
+ } else {
679
+ // No access_token and no error - unexpected, but continue polling
680
+ this.logger.warn(`Unexpected response format, continuing to poll...`);
681
+ await new Promise(resolve => setTimeout(resolve, interval * 1000));
682
+ continue;
683
+ }
684
+ } catch (error) {
685
+ // On HTTP errors, log and continue polling (might be temporary network issue)
686
+ this.logger.warn(`Polling error (will retry): ${error instanceof Error ? error.message : String(error)}`);
687
+ await new Promise(resolve => setTimeout(resolve, interval * 1000));
688
+ continue;
689
+ }
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Start automatic background polling for device token (kept for backward compatibility)
695
+ * @deprecated Use pollUntilApproved for synchronous polling
696
+ */
697
+ private startAutomaticPolling(deviceCode: string, expiresIn: number, interval: number): void {
698
+ // Clear any existing polling for this device code
699
+ const existingTimeout = this.activePolling.get(deviceCode);
700
+ if (existingTimeout) {
701
+ clearTimeout(existingTimeout);
702
+ }
703
+
704
+ this.logger.info(`Starting automatic polling for device code (expires in ${expiresIn}s, polling every ${interval}s)`);
705
+
706
+ const startTime = Date.now();
707
+ const expireTime = startTime + (expiresIn * 1000);
708
+ let pollCount = 0;
709
+
710
+ const poll = async () => {
711
+ const now = Date.now();
712
+ pollCount++;
713
+
714
+ // Check if expired
715
+ if (now >= expireTime) {
716
+ this.logger.warn('Device code expired, stopping automatic polling');
717
+ this.activePolling.delete(deviceCode);
718
+ return;
719
+ }
720
+
721
+ try {
722
+ this.logger.info(`Polling for token (attempt ${pollCount})...`);
723
+ const response = await this.apiClient.pollDeviceToken(deviceCode);
724
+
725
+ if (response.access_token) {
726
+ // Success! Token received
727
+ this.logger.info('✅ Device token received successfully via automatic polling');
728
+ this.activePolling.delete(deviceCode);
729
+ return;
730
+ } else if (response.error) {
731
+ if (response.error === 'authorization_pending') {
732
+ // Continue polling
733
+ this.logger.info(`⏳ Authorization still pending (attempt ${pollCount}), continuing to poll...`);
734
+ const timeout = setTimeout(poll, interval * 1000);
735
+ this.activePolling.set(deviceCode, timeout);
736
+ } else if (response.error === 'access_denied') {
737
+ this.logger.warn('❌ User denied access, stopping polling');
738
+ this.activePolling.delete(deviceCode);
739
+ } else if (response.error === 'expired_token') {
740
+ this.logger.warn('❌ Device code expired, stopping polling');
741
+ this.activePolling.delete(deviceCode);
742
+ } else {
743
+ this.logger.warn(`Unexpected error: ${response.error}, stopping polling`);
744
+ this.activePolling.delete(deviceCode);
745
+ }
746
+ } else {
747
+ // No access_token and no error - unexpected, but continue polling
748
+ this.logger.warn(`Unexpected response format, continuing to poll...`);
749
+ const timeout = setTimeout(poll, interval * 1000);
750
+ this.activePolling.set(deviceCode, timeout);
751
+ }
752
+ } catch (error) {
753
+ // On HTTP errors, log and continue polling (might be temporary network issue)
754
+ this.logger.warn(`Polling error (will retry): ${error instanceof Error ? error.message : String(error)}`);
755
+ const timeout = setTimeout(poll, interval * 1000);
756
+ this.activePolling.set(deviceCode, timeout);
757
+ }
758
+ };
759
+
760
+ // Start first poll immediately (don't wait for interval)
761
+ poll();
762
+ }
763
+
764
+ private async handleDeviceTokenPoll(args: any): Promise<CallToolResult> {
765
+ this.logger.info('Device token poll requested');
766
+
767
+ if (!args.device_code) {
768
+ throw new Error('device_code is required');
769
+ }
770
+
771
+ try {
772
+ const response = await this.apiClient.pollDeviceToken(args.device_code);
773
+
774
+ return {
775
+ content: [
776
+ {
777
+ type: 'text',
778
+ text: JSON.stringify(response, null, 2),
779
+ },
780
+ ],
781
+ isError: false,
782
+ };
783
+ } catch (error) {
784
+ this.logger.error('Device token poll failed:', error);
785
+ throw error;
786
+ }
787
+ }
788
+
789
+ private async handleBackendHealthCheck(): Promise<CallToolResult> {
790
+ this.logger.info('Backend health check requested');
791
+
792
+ try {
793
+ const response = await this.apiClient.healthCheck();
794
+
795
+ return {
796
+ content: [
797
+ {
798
+ type: 'text',
799
+ text: JSON.stringify(response, null, 2),
800
+ },
801
+ ],
802
+ isError: false,
803
+ };
804
+ } catch (error) {
805
+ this.logger.error('Backend health check failed:', error);
806
+ throw error;
807
+ }
808
+ }
809
+
810
+ private async handleDiagramCreate(args: any): Promise<CallToolResult> {
811
+ this.logger.info('Diagram create requested');
812
+
813
+ if (!args.user_uuid || !args.title) {
814
+ throw new Error('user_uuid and title are required');
815
+ }
816
+
817
+ try {
818
+ if (!args.type) {
819
+ throw new Error('type is required');
820
+ }
821
+
822
+ const response = await this.apiClient.createDiagram(args.user_uuid, {
823
+ title: args.title,
824
+ type: args.type,
825
+ });
826
+
827
+ // Return the response in the format specified by the design document
828
+ const result = {
829
+ diagram_uuid: response.uuid,
830
+ external_id: response.label, // Using label as external_id for now
831
+ title: response.title,
832
+ type: response.type,
833
+ version: 1, // Default version for new diagrams
834
+ };
835
+
836
+ return {
837
+ content: [
838
+ {
839
+ type: 'text',
840
+ text: JSON.stringify(result, null, 2),
841
+ },
842
+ ],
843
+ isError: false,
844
+ };
845
+ } catch (error) {
846
+ this.logger.error('Diagram create failed:', error);
847
+ throw error;
848
+ }
849
+ }
850
+
851
+ private async handleDiagramGet(args: any): Promise<CallToolResult> {
852
+ this.logger.info('Diagram get requested');
853
+
854
+ if (!args.diagram_uuid) {
855
+ throw new Error('diagram_uuid is required');
856
+ }
857
+
858
+ try {
859
+ const response = await this.apiClient.getDiagram(args.diagram_uuid);
860
+
861
+ // Return the response in the format specified by the design document
862
+ const result = {
863
+ diagram: {
864
+ meta: {
865
+ uuid: response.uuid,
866
+ title: response.title,
867
+ type: response.type,
868
+ createdAt: response.createdAt,
869
+ updatedAt: response.updatedAt,
870
+ user: response.user,
871
+ },
872
+ nodes: response.nodes,
873
+ links: response.links,
874
+ },
875
+ };
876
+
877
+ return {
878
+ content: [
879
+ {
880
+ type: 'text',
881
+ text: JSON.stringify(result, null, 2),
882
+ },
883
+ ],
884
+ isError: false,
885
+ };
886
+ } catch (error) {
887
+ this.logger.error('Diagram get failed:', error);
888
+ throw error;
889
+ }
890
+ }
891
+
892
+ private async handleDiagramsList(args: any): Promise<CallToolResult> {
893
+ this.logger.info('Diagrams list requested');
894
+
895
+ if (!args.user_uuid) {
896
+ throw new Error('user_uuid is required');
897
+ }
898
+
899
+ try {
900
+ const response = await this.apiClient.getUserDiagrams(
901
+ args.user_uuid,
902
+ args.page || 1,
903
+ args.page_size || 10
904
+ );
905
+
906
+ // Return the response in the format specified by the design document
907
+ const result = {
908
+ items: response.diagrams.map(diagram => ({
909
+ diagram_uuid: diagram.uuid,
910
+ external_id: diagram.label, // Using label as external_id for now
911
+ title: diagram.title,
912
+ type: diagram.type,
913
+ version: 1, // Default version
914
+ updated_at: diagram.updatedAt,
915
+ })),
916
+ next_page: response.pagination.page < response.pagination.totalPages
917
+ ? (response.pagination.page + 1).toString()
918
+ : undefined,
919
+ };
920
+
921
+ return {
922
+ content: [
923
+ {
924
+ type: 'text',
925
+ text: JSON.stringify(result, null, 2),
926
+ },
927
+ ],
928
+ isError: false,
929
+ };
930
+ } catch (error) {
931
+ this.logger.error('Diagrams list failed:', error);
932
+ throw error;
933
+ }
934
+ }
935
+
936
+ private async handleNodesCreate(args: any): Promise<CallToolResult> {
937
+ this.logger.info('Nodes create requested');
938
+
939
+ if (!args.diagram_uuid || !args.kind) {
940
+ throw new Error('diagram_uuid and kind are required');
941
+ }
942
+
943
+ try {
944
+ const requestPayload = {
945
+ kind: args.kind,
946
+ name: args.name || null,
947
+ fields: args.fields || null,
948
+ methods: args.methods || null,
949
+ enum_values: args.enumValues || null,
950
+ stereotypes: args.stereotypes || null,
951
+ group: args.group || null,
952
+ meta: args.meta || null,
953
+ };
954
+
955
+ // Log the request payload for debugging enum values
956
+ if (args.kind === 'enum') {
957
+ this.logger.info(`Creating enum node with enum_values: ${JSON.stringify(requestPayload.enum_values)}`);
958
+ }
959
+
960
+ const response = await this.apiClient.createNode(args.diagram_uuid, requestPayload);
961
+
962
+ return {
963
+ content: [
964
+ {
965
+ type: 'text',
966
+ text: JSON.stringify({
967
+ node: response,
968
+ version: 1, // Default version
969
+ }, null, 2),
970
+ },
971
+ ],
972
+ isError: false,
973
+ };
974
+ } catch (error) {
975
+ this.logger.error('Nodes create failed:', error);
976
+ throw error;
977
+ }
978
+ }
979
+
980
+ private async handleNodeUpdate(args: any): Promise<CallToolResult> {
981
+ this.logger.info('Node update requested');
982
+
983
+ if (!args.diagram_uuid || !args.node_id || !args.patch) {
984
+ throw new Error('diagram_uuid, node_id, and patch are required');
985
+ }
986
+
987
+ try {
988
+ const response = await this.apiClient.updateNode(args.diagram_uuid, args.node_id, args.patch);
989
+
990
+ return {
991
+ content: [
992
+ {
993
+ type: 'text',
994
+ text: JSON.stringify({
995
+ node: response,
996
+ version: 1, // Default version
997
+ }, null, 2),
998
+ },
999
+ ],
1000
+ isError: false,
1001
+ };
1002
+ } catch (error) {
1003
+ this.logger.error('Node update failed:', error);
1004
+ throw error;
1005
+ }
1006
+ }
1007
+
1008
+ private async handleNodeVerify(args: any): Promise<CallToolResult> {
1009
+ this.logger.info('Node verify requested');
1010
+
1011
+ if (!args.diagram_uuid || !args.node_id || !args.structural_fields) {
1012
+ throw new Error('diagram_uuid, node_id, and structural_fields are required');
1013
+ }
1014
+
1015
+ try {
1016
+ const response = await this.apiClient.verifyNode(args.diagram_uuid, args.node_id, args.structural_fields);
1017
+
1018
+ return {
1019
+ content: [
1020
+ {
1021
+ type: 'text',
1022
+ text: JSON.stringify(response, null, 2),
1023
+ },
1024
+ ],
1025
+ isError: false,
1026
+ };
1027
+ } catch (error) {
1028
+ this.logger.error('Node verify failed:', error);
1029
+ throw error;
1030
+ }
1031
+ }
1032
+
1033
+ private async handleNodeDelete(args: any): Promise<CallToolResult> {
1034
+ this.logger.info('Node delete requested');
1035
+
1036
+ if (!args.diagram_uuid || !args.node_id) {
1037
+ throw new Error('diagram_uuid and node_id are required');
1038
+ }
1039
+
1040
+ try {
1041
+ const response = await this.apiClient.deleteNode(args.diagram_uuid, args.node_id);
1042
+
1043
+ return {
1044
+ content: [
1045
+ {
1046
+ type: 'text',
1047
+ text: JSON.stringify(response, null, 2),
1048
+ },
1049
+ ],
1050
+ isError: false,
1051
+ };
1052
+ } catch (error) {
1053
+ this.logger.error('Node delete failed:', error);
1054
+ throw error;
1055
+ }
1056
+ }
1057
+
1058
+ private async handleLinksCreate(args: any): Promise<CallToolResult> {
1059
+ this.logger.info('Links create requested');
1060
+
1061
+ if (!args.diagram_uuid || !args.kind || !args.from || !args.to) {
1062
+ throw new Error('diagram_uuid, kind, from, and to are required');
1063
+ }
1064
+
1065
+ // Validate that "none" is not used for inheritance/implements/dependency links
1066
+ const directionalRequiredKinds = ['inheritance', 'implements', 'dependency'];
1067
+ if (args.directional === 'none' && directionalRequiredKinds.includes(args.kind)) {
1068
+ throw new Error(`Link kind "${args.kind}" requires directional to be "unidirectional" or "bidirectional". "none" is not allowed.`);
1069
+ }
1070
+
1071
+ try {
1072
+ const requestBody: any = {
1073
+ kind: args.kind,
1074
+ fromNode: args.from,
1075
+ toNode: args.to,
1076
+ name: args.label || null,
1077
+ meta: args.meta || null,
1078
+ fromMultiplicity: args.fromMultiplicity || null,
1079
+ toMultiplicity: args.toMultiplicity || null,
1080
+ };
1081
+
1082
+ // Only include directional if it was provided
1083
+ if (args.directional !== undefined) {
1084
+ requestBody.directional = args.directional;
1085
+ }
1086
+
1087
+ const response = await this.apiClient.createLink(args.diagram_uuid, requestBody);
1088
+
1089
+ return {
1090
+ content: [
1091
+ {
1092
+ type: 'text',
1093
+ text: JSON.stringify({
1094
+ link: response,
1095
+ version: 1, // Default version
1096
+ }, null, 2),
1097
+ },
1098
+ ],
1099
+ isError: false,
1100
+ };
1101
+ } catch (error) {
1102
+ this.logger.error('Links create failed:', error);
1103
+ throw error;
1104
+ }
1105
+ }
1106
+
1107
+ private async handleLinkUpdate(args: any): Promise<CallToolResult> {
1108
+ this.logger.info('Link update requested');
1109
+
1110
+ if (!args.diagram_uuid || !args.link_id || !args.patch) {
1111
+ throw new Error('diagram_uuid, link_id, and patch are required');
1112
+ }
1113
+
1114
+ try {
1115
+ const response = await this.apiClient.updateLink(args.diagram_uuid, args.link_id, args.patch);
1116
+
1117
+ return {
1118
+ content: [
1119
+ {
1120
+ type: 'text',
1121
+ text: JSON.stringify({
1122
+ link: response,
1123
+ version: 1, // Default version
1124
+ }, null, 2),
1125
+ },
1126
+ ],
1127
+ isError: false,
1128
+ };
1129
+ } catch (error) {
1130
+ this.logger.error('Link update failed:', error);
1131
+ throw error;
1132
+ }
1133
+ }
1134
+
1135
+ private async handleLinkVerify(args: any): Promise<CallToolResult> {
1136
+ this.logger.info('Link verify requested');
1137
+
1138
+ if (!args.diagram_uuid || !args.link_id || !args.structural_fields) {
1139
+ throw new Error('diagram_uuid, link_id, and structural_fields are required');
1140
+ }
1141
+
1142
+ try {
1143
+ const response = await this.apiClient.verifyLink(args.diagram_uuid, args.link_id, args.structural_fields);
1144
+
1145
+ return {
1146
+ content: [
1147
+ {
1148
+ type: 'text',
1149
+ text: JSON.stringify(response, null, 2),
1150
+ },
1151
+ ],
1152
+ isError: false,
1153
+ };
1154
+ } catch (error) {
1155
+ this.logger.error('Link verify failed:', error);
1156
+ throw error;
1157
+ }
1158
+ }
1159
+
1160
+ private async handleLinkDelete(args: any): Promise<CallToolResult> {
1161
+ this.logger.info('Link delete requested');
1162
+
1163
+ if (!args.diagram_uuid || !args.link_id) {
1164
+ throw new Error('diagram_uuid and link_id are required');
1165
+ }
1166
+
1167
+ try {
1168
+ const response = await this.apiClient.deleteLink(args.diagram_uuid, args.link_id);
1169
+
1170
+ return {
1171
+ content: [
1172
+ {
1173
+ type: 'text',
1174
+ text: JSON.stringify(response, null, 2),
1175
+ },
1176
+ ],
1177
+ isError: false,
1178
+ };
1179
+ } catch (error) {
1180
+ this.logger.error('Link delete failed:', error);
1181
+ throw error;
1182
+ }
1183
+ }
1184
+
1185
+ async start(transport: StdioServerTransport) {
1186
+ await this.server.connect(transport);
1187
+ this.logger.info('Planform MCP Server connected and ready');
1188
+ }
1189
+ }