ingresseflow-bridge 1.0.1 → 1.1.0

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/dist/index.js CHANGED
@@ -14,10 +14,40 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
14
14
  import { WebSocketServer, WebSocket } from 'ws';
15
15
  import { createServer } from 'http';
16
16
  import { z } from 'zod';
17
- // ── WebSocket bridge ──────────────────────────────────────────────────────────
17
+ const HTTP_PORT = 9242;
18
+ function log(msg) {
19
+ process.stderr.write(`[IngresseFlow] ${msg}\n`);
20
+ }
21
+ // ── Bridge state ──────────────────────────────────────────────────────────────
22
+ // Two boot modes:
23
+ // - 'server': this process owns the WebSocket bridge and HTTP command server.
24
+ // - 'client': another bridge already owns :9242 (typically the LaunchAgent).
25
+ // This process is just an MCP front-end and forwards calls via HTTP.
26
+ // The client mode prevents EADDRINUSE crashes when Claude Code spawns a stdio
27
+ // MCP child while the persistent bridge is already running.
28
+ let mode = 'server';
18
29
  let pluginSocket = null;
19
30
  const pending = new Map();
31
+ async function detectExistingBridge() {
32
+ // If :9242 is occupied, another bridge owns it — go into client mode.
33
+ // tryPort returns false when listen fails (EADDRINUSE), which is exactly
34
+ // the signal we need; works even against older bridges without /health.
35
+ return !(await tryPort(HTTP_PORT));
36
+ }
37
+ async function forwardViaHttp(type, payload) {
38
+ const r = await fetch(`http://localhost:${HTTP_PORT}/command`, {
39
+ method: 'POST',
40
+ headers: { 'Content-Type': 'application/json' },
41
+ body: JSON.stringify({ type, payload }),
42
+ });
43
+ const json = await r.json();
44
+ if (!json.success)
45
+ throw new Error(json.error ?? 'Erro desconhecido');
46
+ return json.data;
47
+ }
20
48
  function sendToPlugin(type, payload) {
49
+ if (mode === 'client')
50
+ return forwardViaHttp(type, payload);
21
51
  return new Promise((resolve, reject) => {
22
52
  if (!pluginSocket || pluginSocket.readyState !== WebSocket.OPEN) {
23
53
  return reject(new Error('Plugin não conectado. Abra o IngresseFlow no Figma Desktop (FigJam).'));
@@ -35,14 +65,14 @@ function sendToPlugin(type, payload) {
35
65
  pluginSocket.send(JSON.stringify({ id, type, payload }));
36
66
  });
37
67
  }
38
- async function tryPort(port) {
68
+ async function tryPort(port, host = '127.0.0.1') {
39
69
  return new Promise(resolve => {
40
70
  const srv = createServer();
41
71
  srv.once('error', () => resolve(false));
42
- srv.listen(port, () => srv.close(() => resolve(true)));
72
+ srv.listen(port, host, () => srv.close(() => resolve(true)));
43
73
  });
44
74
  }
45
- async function startBridge() {
75
+ async function startWebSocketBridge() {
46
76
  const BASE_PORT = 9243;
47
77
  const MAX_PORT = 9250;
48
78
  let port = BASE_PORT;
@@ -54,6 +84,9 @@ async function startBridge() {
54
84
  if (port > MAX_PORT)
55
85
  throw new Error('Nenhuma porta livre em 9243–9250');
56
86
  const wss = new WebSocketServer({ port, host: '127.0.0.1' });
87
+ wss.on('error', (err) => {
88
+ log(`Erro no WebSocketServer: ${err.message}`);
89
+ });
57
90
  wss.on('connection', (ws) => {
58
91
  pluginSocket = ws;
59
92
  log(`Plugin conectado`);
@@ -82,6 +115,44 @@ async function startBridge() {
82
115
  log(`Abra o plugin IngresseFlow no Figma Desktop para conectar`);
83
116
  return port;
84
117
  }
118
+ // ── HTTP command server ──────────────────────────────────────────────────────
119
+ // Exposes:
120
+ // GET /health — used by other bridge instances to detect us and switch
121
+ // to client mode instead of crashing on EADDRINUSE.
122
+ // POST /command — { type, payload } forwarded to the plugin via WebSocket.
123
+ function startCommandServer() {
124
+ const http = createServer(async (req, res) => {
125
+ if (req.method === 'GET' && req.url === '/health') {
126
+ res.writeHead(200, { 'Content-Type': 'application/json' });
127
+ res.end(JSON.stringify({ status: 'ok', plugin: !!pluginSocket }));
128
+ return;
129
+ }
130
+ if (req.method !== 'POST' || req.url !== '/command') {
131
+ res.writeHead(404).end();
132
+ return;
133
+ }
134
+ let body = '';
135
+ req.on('data', (c) => { body += c; });
136
+ req.on('end', async () => {
137
+ try {
138
+ const { type, payload } = JSON.parse(body);
139
+ const result = await sendToPlugin(type, payload);
140
+ res.writeHead(200, { 'Content-Type': 'application/json' });
141
+ res.end(JSON.stringify({ success: true, data: result }));
142
+ }
143
+ catch (err) {
144
+ res.writeHead(200, { 'Content-Type': 'application/json' });
145
+ res.end(JSON.stringify({ success: false, error: err.message }));
146
+ }
147
+ });
148
+ });
149
+ http.on('error', (err) => {
150
+ log(`Erro no HTTP server: ${err.message}`);
151
+ });
152
+ http.listen(HTTP_PORT, '127.0.0.1', () => {
153
+ log(`Comando HTTP em http://localhost:${HTTP_PORT}/command`);
154
+ });
155
+ }
85
156
  // ── MCP tools ─────────────────────────────────────────────────────────────────
86
157
  const ColorEnum = z.enum([
87
158
  'YELLOW', 'BLUE', 'GREEN', 'PINK', 'ORANGE',
@@ -126,37 +197,123 @@ server.tool('figjam_create_section', 'Cria uma seção nomeada no FigJam para ag
126
197
  const result = await sendToPlugin('CREATE_SECTION', { name, x, y, width, height });
127
198
  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
128
199
  });
129
- // ── HTTP command server (port 9242) ───────────────────────────────────────────
130
- // Accepts POST /command { type, payload } — lets any local client send operations
131
- // without needing an MCP session. Used by scripts and tests.
132
- function startCommandServer() {
133
- const http = createServer(async (req, res) => {
134
- if (req.method !== 'POST' || req.url !== '/command') {
135
- res.writeHead(404).end();
136
- return;
137
- }
138
- let body = '';
139
- req.on('data', (c) => { body += c; });
140
- req.on('end', async () => {
141
- try {
142
- const { type, payload } = JSON.parse(body);
143
- const result = await sendToPlugin(type, payload);
144
- res.writeHead(200, { 'Content-Type': 'application/json' });
145
- res.end(JSON.stringify({ success: true, data: result }));
146
- }
147
- catch (err) {
148
- res.writeHead(200, { 'Content-Type': 'application/json' });
149
- res.end(JSON.stringify({ success: false, error: err.message }));
150
- }
151
- });
152
- });
153
- http.listen(9242, '127.0.0.1', () => log('Comando HTTP em http://localhost:9242/command'));
154
- }
200
+ // ── Shapes & connectors (BPMN-friendly) ───────────────────────────────────────
201
+ const ShapeTypeEnum = z.enum([
202
+ 'SQUARE', 'ELLIPSE', 'ROUNDED_RECTANGLE', 'DIAMOND',
203
+ 'TRIANGLE_UP', 'TRIANGLE_DOWN',
204
+ 'PARALLELOGRAM_RIGHT', 'PARALLELOGRAM_LEFT',
205
+ 'ENG_DATABASE', 'ENG_QUEUE', 'ENG_FILE', 'ENG_FOLDER',
206
+ 'TRAPEZOID', 'PREDEFINED_PROCESS', 'SHIELD',
207
+ 'DOCUMENT_SINGLE', 'DOCUMENT_MULTIPLE', 'MANUAL_INPUT',
208
+ 'HEXAGON', 'CHEVRON_RIGHT_ARROW', 'CHEVRON_RIGHT_PENTAGON',
209
+ ]).describe('Tipo de shape (BPMN: ELLIPSE=evento, ROUNDED_RECTANGLE=tarefa, DIAMOND=gateway)');
210
+ const MagnetEnum = z.enum(['AUTO', 'TOP', 'LEFT', 'BOTTOM', 'RIGHT', 'CENTER'])
211
+ .describe('Ponto de ancoragem do conector no nó');
212
+ const StrokeCapEnum = z.enum([
213
+ 'NONE', 'ARROW_LINES', 'ARROW_EQUILATERAL',
214
+ 'TRIANGLE_FILLED', 'DIAMOND_FILLED', 'CIRCLE_FILLED',
215
+ ]).describe('Estilo da ponta do conector');
216
+ const LineTypeEnum = z.enum(['STRAIGHT', 'ELBOWED'])
217
+ .describe('Reta direta ou com cotovelos (90°)');
218
+ const ShapeItem = z.object({
219
+ shape: ShapeTypeEnum.default('ROUNDED_RECTANGLE'),
220
+ text: z.string().optional().describe('Texto interno do shape'),
221
+ color: ColorEnum.default('BLUE'),
222
+ x: z.number().optional(),
223
+ y: z.number().optional(),
224
+ width: z.number().optional(),
225
+ height: z.number().optional(),
226
+ });
227
+ const ConnectorItem = z.object({
228
+ startNodeId: z.string().describe('ID do nó de origem'),
229
+ endNodeId: z.string().describe('ID do nó de destino'),
230
+ startMagnet: MagnetEnum.default('AUTO'),
231
+ endMagnet: MagnetEnum.default('AUTO'),
232
+ label: z.string().optional().describe('Texto sobre o conector (ex.: Sim/Não)'),
233
+ lineType: LineTypeEnum.optional(),
234
+ startCap: StrokeCapEnum.optional(),
235
+ endCap: StrokeCapEnum.optional(),
236
+ strokeWeight: z.number().optional(),
237
+ color: ColorEnum.optional(),
238
+ });
239
+ server.tool('figjam_create_shape', 'Cria uma forma com texto no FigJam (BPMN: ELLIPSE=evento, ROUNDED_RECTANGLE=tarefa, DIAMOND=gateway). Retorna nodeId para conectar.', ShapeItem.shape, async (params) => {
240
+ const result = await sendToPlugin('CREATE_SHAPE', params);
241
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
242
+ });
243
+ server.tool('figjam_create_shapes', 'Cria múltiplas formas com texto de uma vez. Máximo 50 por chamada. Use com figjam_create_connectors para montar fluxos BPMN.', {
244
+ shapes: z.array(ShapeItem).max(50).describe('Lista de formas para criar'),
245
+ }, async ({ shapes }) => {
246
+ const result = await sendToPlugin('CREATE_SHAPES', { shapes });
247
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
248
+ });
249
+ server.tool('figjam_create_connector', 'Conecta dois nós existentes com uma seta (BPMN sequence flow). Aceita nodeId de stickies, shapes ou qualquer SceneNode.', ConnectorItem.shape, async (params) => {
250
+ const result = await sendToPlugin('CREATE_CONNECTOR', params);
251
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
252
+ });
253
+ server.tool('figjam_create_connectors', 'Cria múltiplos conectores de uma vez. Máximo 100 por chamada. Use após figjam_create_shapes para montar fluxos completos.', {
254
+ connectors: z.array(ConnectorItem).max(100).describe('Lista de conectores'),
255
+ }, async ({ connectors }) => {
256
+ const result = await sendToPlugin('CREATE_CONNECTORS', { connectors });
257
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
258
+ });
259
+ server.tool('figjam_create_flow', 'Atalho: cria um fluxo linear (shapes em sequência horizontal já conectados). Para fluxos com ramificações use figjam_create_shapes + figjam_create_connectors.', {
260
+ steps: z.array(z.object({
261
+ label: z.string(),
262
+ shape: ShapeTypeEnum.optional(),
263
+ color: ColorEnum.optional(),
264
+ })).describe('Etapas do fluxo, da esquerda para a direita'),
265
+ x: z.number().optional().describe('X inicial do primeiro shape'),
266
+ y: z.number().optional().describe('Y comum a todos os shapes'),
267
+ gap: z.number().default(220).describe('Distância horizontal entre shapes'),
268
+ }, async ({ steps, x, y, gap }) => {
269
+ const result = await sendToPlugin('CREATE_FLOW', { steps, x, y, gap });
270
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
271
+ });
272
+ server.tool('figjam_set_connector_labels', 'Define ou atualiza rótulos em conectores existentes (ex.: Sim/Não em gateways BPMN).', {
273
+ labels: z.array(z.object({
274
+ nodeId: z.string(),
275
+ label: z.string(),
276
+ })).describe('Pares { nodeId, label } para conectores existentes'),
277
+ }, async ({ labels }) => {
278
+ const result = await sendToPlugin('SET_CONNECTOR_LABELS', { labels });
279
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
280
+ });
281
+ server.tool('figjam_move_nodes', 'Reposiciona nós existentes pelo ID.', {
282
+ moves: z.array(z.object({
283
+ nodeId: z.string(),
284
+ x: z.number(),
285
+ y: z.number(),
286
+ })),
287
+ }, async ({ moves }) => {
288
+ const result = await sendToPlugin('MOVE_NODES', { moves });
289
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
290
+ });
291
+ server.tool('figjam_delete_nodes', 'Remove nós do canvas pelo ID.', {
292
+ nodeIds: z.array(z.string()),
293
+ }, async ({ nodeIds }) => {
294
+ const result = await sendToPlugin('DELETE_NODES', { nodeIds });
295
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
296
+ });
297
+ // Generic forwarder — lets new plugin operations be invoked without bridge updates.
298
+ // Useful for evolving the plugin independently: any new op type added in code.ts
299
+ // becomes immediately accessible via this tool.
300
+ server.tool('figjam_execute', 'Encaminha uma operação arbitrária para o plugin (escape hatch para ops novas que ainda não têm tool dedicada). Tipos válidos atuais: PING, CREATE_STICKY, CREATE_STICKIES, CREATE_SECTION, CREATE_SHAPE, CREATE_SHAPES, CREATE_CONNECTOR, CREATE_CONNECTORS, CREATE_FLOW, SET_CONNECTOR_LABELS, MOVE_NODES, DELETE_NODES, READ_BOARD, GET_SELECTION.', {
301
+ type: z.string().describe('Nome da operação no plugin (ex.: CREATE_SHAPE)'),
302
+ payload: z.record(z.unknown()).optional().describe('Payload da operação'),
303
+ }, async ({ type, payload }) => {
304
+ const result = await sendToPlugin(type, payload ?? {});
305
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
306
+ });
155
307
  // ── Boot ──────────────────────────────────────────────────────────────────────
156
- function log(msg) {
157
- process.stderr.write(`[IngresseFlow] ${msg}\n`);
308
+ const existing = await detectExistingBridge();
309
+ if (existing) {
310
+ mode = 'client';
311
+ log(`Bridge persistente detectada em :${HTTP_PORT} — operando em modo cliente.`);
312
+ }
313
+ else {
314
+ mode = 'server';
315
+ await startWebSocketBridge();
316
+ startCommandServer();
158
317
  }
159
- await startBridge();
160
- startCommandServer();
161
318
  const transport = new StdioServerTransport();
162
319
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ingresseflow-bridge",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Bridge MCP server for IngresseFlow — connects Claude Code to FigJam via Figma Desktop plugin",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.ts CHANGED
@@ -11,8 +11,21 @@ import { WebSocketServer, WebSocket } from 'ws';
11
11
  import { createServer } from 'http';
12
12
  import { z } from 'zod';
13
13
 
14
- // ── WebSocket bridge ──────────────────────────────────────────────────────────
14
+ const HTTP_PORT = 9242;
15
15
 
16
+ function log(msg: string) {
17
+ process.stderr.write(`[IngresseFlow] ${msg}\n`);
18
+ }
19
+
20
+ // ── Bridge state ──────────────────────────────────────────────────────────────
21
+ // Two boot modes:
22
+ // - 'server': this process owns the WebSocket bridge and HTTP command server.
23
+ // - 'client': another bridge already owns :9242 (typically the LaunchAgent).
24
+ // This process is just an MCP front-end and forwards calls via HTTP.
25
+ // The client mode prevents EADDRINUSE crashes when Claude Code spawns a stdio
26
+ // MCP child while the persistent bridge is already running.
27
+
28
+ let mode: 'server' | 'client' = 'server';
16
29
  let pluginSocket: WebSocket | null = null;
17
30
 
18
31
  const pending = new Map<string, {
@@ -21,7 +34,27 @@ const pending = new Map<string, {
21
34
  timer: ReturnType<typeof setTimeout>;
22
35
  }>();
23
36
 
37
+ async function detectExistingBridge(): Promise<boolean> {
38
+ // If :9242 is occupied, another bridge owns it — go into client mode.
39
+ // tryPort returns false when listen fails (EADDRINUSE), which is exactly
40
+ // the signal we need; works even against older bridges without /health.
41
+ return !(await tryPort(HTTP_PORT));
42
+ }
43
+
44
+ async function forwardViaHttp<T>(type: string, payload: unknown): Promise<T> {
45
+ const r = await fetch(`http://localhost:${HTTP_PORT}/command`, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify({ type, payload }),
49
+ });
50
+ const json = await r.json() as { success: boolean; data?: T; error?: string };
51
+ if (!json.success) throw new Error(json.error ?? 'Erro desconhecido');
52
+ return json.data as T;
53
+ }
54
+
24
55
  function sendToPlugin<T>(type: string, payload: unknown): Promise<T> {
56
+ if (mode === 'client') return forwardViaHttp<T>(type, payload);
57
+
25
58
  return new Promise((resolve, reject) => {
26
59
  if (!pluginSocket || pluginSocket.readyState !== WebSocket.OPEN) {
27
60
  return reject(new Error(
@@ -44,15 +77,15 @@ function sendToPlugin<T>(type: string, payload: unknown): Promise<T> {
44
77
  });
45
78
  }
46
79
 
47
- async function tryPort(port: number): Promise<boolean> {
80
+ async function tryPort(port: number, host = '127.0.0.1'): Promise<boolean> {
48
81
  return new Promise(resolve => {
49
82
  const srv = createServer();
50
83
  srv.once('error', () => resolve(false));
51
- srv.listen(port, () => srv.close(() => resolve(true)));
84
+ srv.listen(port, host, () => srv.close(() => resolve(true)));
52
85
  });
53
86
  }
54
87
 
55
- async function startBridge(): Promise<number> {
88
+ async function startWebSocketBridge(): Promise<number> {
56
89
  const BASE_PORT = 9243;
57
90
  const MAX_PORT = 9250;
58
91
  let port = BASE_PORT;
@@ -65,6 +98,10 @@ async function startBridge(): Promise<number> {
65
98
 
66
99
  const wss = new WebSocketServer({ port, host: '127.0.0.1' });
67
100
 
101
+ wss.on('error', (err) => {
102
+ log(`Erro no WebSocketServer: ${(err as Error).message}`);
103
+ });
104
+
68
105
  wss.on('connection', (ws) => {
69
106
  pluginSocket = ws;
70
107
  log(`Plugin conectado`);
@@ -95,6 +132,47 @@ async function startBridge(): Promise<number> {
95
132
  return port;
96
133
  }
97
134
 
135
+ // ── HTTP command server ──────────────────────────────────────────────────────
136
+ // Exposes:
137
+ // GET /health — used by other bridge instances to detect us and switch
138
+ // to client mode instead of crashing on EADDRINUSE.
139
+ // POST /command — { type, payload } forwarded to the plugin via WebSocket.
140
+
141
+ function startCommandServer() {
142
+ const http = createServer(async (req, res) => {
143
+ if (req.method === 'GET' && req.url === '/health') {
144
+ res.writeHead(200, { 'Content-Type': 'application/json' });
145
+ res.end(JSON.stringify({ status: 'ok', plugin: !!pluginSocket }));
146
+ return;
147
+ }
148
+ if (req.method !== 'POST' || req.url !== '/command') {
149
+ res.writeHead(404).end();
150
+ return;
151
+ }
152
+ let body = '';
153
+ req.on('data', (c: Buffer) => { body += c; });
154
+ req.on('end', async () => {
155
+ try {
156
+ const { type, payload } = JSON.parse(body) as { type: string; payload: unknown };
157
+ const result = await sendToPlugin(type, payload);
158
+ res.writeHead(200, { 'Content-Type': 'application/json' });
159
+ res.end(JSON.stringify({ success: true, data: result }));
160
+ } catch (err) {
161
+ res.writeHead(200, { 'Content-Type': 'application/json' });
162
+ res.end(JSON.stringify({ success: false, error: (err as Error).message }));
163
+ }
164
+ });
165
+ });
166
+
167
+ http.on('error', (err) => {
168
+ log(`Erro no HTTP server: ${(err as Error).message}`);
169
+ });
170
+
171
+ http.listen(HTTP_PORT, '127.0.0.1', () => {
172
+ log(`Comando HTTP em http://localhost:${HTTP_PORT}/command`);
173
+ });
174
+ }
175
+
98
176
  // ── MCP tools ─────────────────────────────────────────────────────────────────
99
177
 
100
178
  const ColorEnum = z.enum([
@@ -172,41 +250,185 @@ server.tool(
172
250
  }
173
251
  );
174
252
 
175
- // ── HTTP command server (port 9242) ───────────────────────────────────────────
176
- // Accepts POST /command { type, payload } — lets any local client send operations
177
- // without needing an MCP session. Used by scripts and tests.
253
+ // ── Shapes & connectors (BPMN-friendly) ───────────────────────────────────────
178
254
 
179
- function startCommandServer() {
180
- const http = createServer(async (req, res) => {
181
- if (req.method !== 'POST' || req.url !== '/command') {
182
- res.writeHead(404).end();
183
- return;
184
- }
185
- let body = '';
186
- req.on('data', (c: Buffer) => { body += c; });
187
- req.on('end', async () => {
188
- try {
189
- const { type, payload } = JSON.parse(body) as { type: string; payload: unknown };
190
- const result = await sendToPlugin(type, payload);
191
- res.writeHead(200, { 'Content-Type': 'application/json' });
192
- res.end(JSON.stringify({ success: true, data: result }));
193
- } catch (err) {
194
- res.writeHead(200, { 'Content-Type': 'application/json' });
195
- res.end(JSON.stringify({ success: false, error: (err as Error).message }));
196
- }
197
- });
198
- });
199
- http.listen(9242, '127.0.0.1', () => log('Comando HTTP em http://localhost:9242/command'));
200
- }
255
+ const ShapeTypeEnum = z.enum([
256
+ 'SQUARE', 'ELLIPSE', 'ROUNDED_RECTANGLE', 'DIAMOND',
257
+ 'TRIANGLE_UP', 'TRIANGLE_DOWN',
258
+ 'PARALLELOGRAM_RIGHT', 'PARALLELOGRAM_LEFT',
259
+ 'ENG_DATABASE', 'ENG_QUEUE', 'ENG_FILE', 'ENG_FOLDER',
260
+ 'TRAPEZOID', 'PREDEFINED_PROCESS', 'SHIELD',
261
+ 'DOCUMENT_SINGLE', 'DOCUMENT_MULTIPLE', 'MANUAL_INPUT',
262
+ 'HEXAGON', 'CHEVRON_RIGHT_ARROW', 'CHEVRON_RIGHT_PENTAGON',
263
+ ]).describe('Tipo de shape (BPMN: ELLIPSE=evento, ROUNDED_RECTANGLE=tarefa, DIAMOND=gateway)');
264
+
265
+ const MagnetEnum = z.enum(['AUTO', 'TOP', 'LEFT', 'BOTTOM', 'RIGHT', 'CENTER'])
266
+ .describe('Ponto de ancoragem do conector no nó');
267
+
268
+ const StrokeCapEnum = z.enum([
269
+ 'NONE', 'ARROW_LINES', 'ARROW_EQUILATERAL',
270
+ 'TRIANGLE_FILLED', 'DIAMOND_FILLED', 'CIRCLE_FILLED',
271
+ ]).describe('Estilo da ponta do conector');
272
+
273
+ const LineTypeEnum = z.enum(['STRAIGHT', 'ELBOWED'])
274
+ .describe('Reta direta ou com cotovelos (90°)');
275
+
276
+ const ShapeItem = z.object({
277
+ shape: ShapeTypeEnum.default('ROUNDED_RECTANGLE'),
278
+ text: z.string().optional().describe('Texto interno do shape'),
279
+ color: ColorEnum.default('BLUE'),
280
+ x: z.number().optional(),
281
+ y: z.number().optional(),
282
+ width: z.number().optional(),
283
+ height: z.number().optional(),
284
+ });
285
+
286
+ const ConnectorItem = z.object({
287
+ startNodeId: z.string().describe('ID do nó de origem'),
288
+ endNodeId: z.string().describe('ID do nó de destino'),
289
+ startMagnet: MagnetEnum.default('AUTO'),
290
+ endMagnet: MagnetEnum.default('AUTO'),
291
+ label: z.string().optional().describe('Texto sobre o conector (ex.: Sim/Não)'),
292
+ lineType: LineTypeEnum.optional(),
293
+ startCap: StrokeCapEnum.optional(),
294
+ endCap: StrokeCapEnum.optional(),
295
+ strokeWeight: z.number().optional(),
296
+ color: ColorEnum.optional(),
297
+ });
298
+
299
+ server.tool(
300
+ 'figjam_create_shape',
301
+ 'Cria uma forma com texto no FigJam (BPMN: ELLIPSE=evento, ROUNDED_RECTANGLE=tarefa, DIAMOND=gateway). Retorna nodeId para conectar.',
302
+ ShapeItem.shape,
303
+ async (params) => {
304
+ const result = await sendToPlugin('CREATE_SHAPE', params);
305
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
306
+ }
307
+ );
308
+
309
+ server.tool(
310
+ 'figjam_create_shapes',
311
+ 'Cria múltiplas formas com texto de uma vez. Máximo 50 por chamada. Use com figjam_create_connectors para montar fluxos BPMN.',
312
+ {
313
+ shapes: z.array(ShapeItem).max(50).describe('Lista de formas para criar'),
314
+ },
315
+ async ({ shapes }) => {
316
+ const result = await sendToPlugin('CREATE_SHAPES', { shapes });
317
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
318
+ }
319
+ );
320
+
321
+ server.tool(
322
+ 'figjam_create_connector',
323
+ 'Conecta dois nós existentes com uma seta (BPMN sequence flow). Aceita nodeId de stickies, shapes ou qualquer SceneNode.',
324
+ ConnectorItem.shape,
325
+ async (params) => {
326
+ const result = await sendToPlugin('CREATE_CONNECTOR', params);
327
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
328
+ }
329
+ );
330
+
331
+ server.tool(
332
+ 'figjam_create_connectors',
333
+ 'Cria múltiplos conectores de uma vez. Máximo 100 por chamada. Use após figjam_create_shapes para montar fluxos completos.',
334
+ {
335
+ connectors: z.array(ConnectorItem).max(100).describe('Lista de conectores'),
336
+ },
337
+ async ({ connectors }) => {
338
+ const result = await sendToPlugin('CREATE_CONNECTORS', { connectors });
339
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
340
+ }
341
+ );
342
+
343
+ server.tool(
344
+ 'figjam_create_flow',
345
+ 'Atalho: cria um fluxo linear (shapes em sequência horizontal já conectados). Para fluxos com ramificações use figjam_create_shapes + figjam_create_connectors.',
346
+ {
347
+ steps: z.array(z.object({
348
+ label: z.string(),
349
+ shape: ShapeTypeEnum.optional(),
350
+ color: ColorEnum.optional(),
351
+ })).describe('Etapas do fluxo, da esquerda para a direita'),
352
+ x: z.number().optional().describe('X inicial do primeiro shape'),
353
+ y: z.number().optional().describe('Y comum a todos os shapes'),
354
+ gap: z.number().default(220).describe('Distância horizontal entre shapes'),
355
+ },
356
+ async ({ steps, x, y, gap }) => {
357
+ const result = await sendToPlugin('CREATE_FLOW', { steps, x, y, gap });
358
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
359
+ }
360
+ );
361
+
362
+ server.tool(
363
+ 'figjam_set_connector_labels',
364
+ 'Define ou atualiza rótulos em conectores existentes (ex.: Sim/Não em gateways BPMN).',
365
+ {
366
+ labels: z.array(z.object({
367
+ nodeId: z.string(),
368
+ label: z.string(),
369
+ })).describe('Pares { nodeId, label } para conectores existentes'),
370
+ },
371
+ async ({ labels }) => {
372
+ const result = await sendToPlugin('SET_CONNECTOR_LABELS', { labels });
373
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
374
+ }
375
+ );
376
+
377
+ server.tool(
378
+ 'figjam_move_nodes',
379
+ 'Reposiciona nós existentes pelo ID.',
380
+ {
381
+ moves: z.array(z.object({
382
+ nodeId: z.string(),
383
+ x: z.number(),
384
+ y: z.number(),
385
+ })),
386
+ },
387
+ async ({ moves }) => {
388
+ const result = await sendToPlugin('MOVE_NODES', { moves });
389
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
390
+ }
391
+ );
392
+
393
+ server.tool(
394
+ 'figjam_delete_nodes',
395
+ 'Remove nós do canvas pelo ID.',
396
+ {
397
+ nodeIds: z.array(z.string()),
398
+ },
399
+ async ({ nodeIds }) => {
400
+ const result = await sendToPlugin('DELETE_NODES', { nodeIds });
401
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
402
+ }
403
+ );
404
+
405
+ // Generic forwarder — lets new plugin operations be invoked without bridge updates.
406
+ // Useful for evolving the plugin independently: any new op type added in code.ts
407
+ // becomes immediately accessible via this tool.
408
+ server.tool(
409
+ 'figjam_execute',
410
+ 'Encaminha uma operação arbitrária para o plugin (escape hatch para ops novas que ainda não têm tool dedicada). Tipos válidos atuais: PING, CREATE_STICKY, CREATE_STICKIES, CREATE_SECTION, CREATE_SHAPE, CREATE_SHAPES, CREATE_CONNECTOR, CREATE_CONNECTORS, CREATE_FLOW, SET_CONNECTOR_LABELS, MOVE_NODES, DELETE_NODES, READ_BOARD, GET_SELECTION.',
411
+ {
412
+ type: z.string().describe('Nome da operação no plugin (ex.: CREATE_SHAPE)'),
413
+ payload: z.record(z.unknown()).optional().describe('Payload da operação'),
414
+ },
415
+ async ({ type, payload }) => {
416
+ const result = await sendToPlugin(type, payload ?? {});
417
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
418
+ }
419
+ );
201
420
 
202
421
  // ── Boot ──────────────────────────────────────────────────────────────────────
203
422
 
204
- function log(msg: string) {
205
- process.stderr.write(`[IngresseFlow] ${msg}\n`);
423
+ const existing = await detectExistingBridge();
424
+ if (existing) {
425
+ mode = 'client';
426
+ log(`Bridge persistente detectada em :${HTTP_PORT} — operando em modo cliente.`);
427
+ } else {
428
+ mode = 'server';
429
+ await startWebSocketBridge();
430
+ startCommandServer();
206
431
  }
207
432
 
208
- await startBridge();
209
- startCommandServer();
210
-
211
433
  const transport = new StdioServerTransport();
212
434
  await server.connect(transport);
package/src/index.js DELETED
@@ -1,125 +0,0 @@
1
- #!/usr/bin/env node
2
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import { WebSocketServer, WebSocket } from 'ws';
5
- import { createServer } from 'http';
6
- import { z } from 'zod';
7
- // ── WebSocket bridge ──────────────────────────────────────────────────────────
8
- let pluginSocket = null;
9
- const pending = new Map();
10
- function sendToPlugin(type, payload) {
11
- return new Promise((resolve, reject) => {
12
- if (!pluginSocket || pluginSocket.readyState !== WebSocket.OPEN) {
13
- return reject(new Error('Plugin não conectado. Abra o IngresseFlow no Figma Desktop (FigJam).'));
14
- }
15
- const id = crypto.randomUUID();
16
- const timer = setTimeout(() => {
17
- pending.delete(id);
18
- reject(new Error(`Timeout aguardando resposta para ${type}`));
19
- }, 15000);
20
- pending.set(id, {
21
- resolve: (v) => { clearTimeout(timer); resolve(v); },
22
- reject: (e) => { clearTimeout(timer); reject(e); },
23
- timer,
24
- });
25
- pluginSocket.send(JSON.stringify({ id, type, payload }));
26
- });
27
- }
28
- async function tryPort(port) {
29
- return new Promise(resolve => {
30
- const srv = createServer();
31
- srv.once('error', () => resolve(false));
32
- srv.listen(port, () => srv.close(() => resolve(true)));
33
- });
34
- }
35
- async function startBridge() {
36
- const BASE_PORT = 9243;
37
- const MAX_PORT = 9250;
38
- let port = BASE_PORT;
39
- while (port <= MAX_PORT) {
40
- if (await tryPort(port))
41
- break;
42
- port++;
43
- }
44
- if (port > MAX_PORT)
45
- throw new Error('Nenhuma porta livre em 9243–9250');
46
- const wss = new WebSocketServer({ port });
47
- wss.on('connection', (ws) => {
48
- pluginSocket = ws;
49
- log(`Plugin conectado`);
50
- ws.on('message', (raw) => {
51
- try {
52
- const msg = JSON.parse(raw.toString());
53
- const p = pending.get(msg.id);
54
- if (!p)
55
- return;
56
- pending.delete(msg.id);
57
- if (msg.success)
58
- p.resolve(msg.data);
59
- else
60
- p.reject(new Error(msg.error ?? 'Erro desconhecido'));
61
- }
62
- catch (e) {
63
- log(`Erro ao parsear mensagem: ${e}`);
64
- }
65
- });
66
- ws.on('close', () => {
67
- pluginSocket = null;
68
- log(`Plugin desconectado`);
69
- });
70
- });
71
- log(`Bridge escutando em ws://localhost:${port}`);
72
- log(`Abra o plugin IngresseFlow no Figma Desktop para conectar`);
73
- return port;
74
- }
75
- // ── MCP tools ─────────────────────────────────────────────────────────────────
76
- const ColorEnum = z.enum([
77
- 'YELLOW', 'BLUE', 'GREEN', 'PINK', 'ORANGE',
78
- 'PURPLE', 'RED', 'GRAY', 'LIGHT_GRAY',
79
- ]);
80
- const server = new McpServer({ name: 'ingresseflow', version: '1.0.0' });
81
- server.tool('figjam_create_sticky', 'Cria um post-it no FigJam. Requer o plugin IngresseFlow aberto no Figma Desktop.', {
82
- text: z.string().describe('Texto do post-it'),
83
- color: ColorEnum.default('YELLOW').describe('Cor de fundo'),
84
- x: z.number().optional().describe('Posição X no canvas'),
85
- y: z.number().optional().describe('Posição Y no canvas'),
86
- }, async ({ text, color, x, y }) => {
87
- const result = await sendToPlugin('CREATE_STICKY', { text, color, x, y });
88
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
89
- });
90
- server.tool('figjam_create_stickies', 'Cria múltiplos post-its de uma vez no FigJam. Máximo 50 por chamada. Use para documentar análises completas.', {
91
- stickies: z.array(z.object({
92
- text: z.string(),
93
- color: ColorEnum.default('YELLOW'),
94
- x: z.number().optional(),
95
- y: z.number().optional(),
96
- })).max(50).describe('Lista de post-its para criar'),
97
- }, async ({ stickies }) => {
98
- const result = await sendToPlugin('CREATE_STICKIES', { stickies });
99
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
100
- });
101
- server.tool('figjam_read_board', 'Lê todo o conteúdo do FigJam atual. Retorna nós com texto, posição e tipo.', {}, async () => {
102
- const result = await sendToPlugin('READ_BOARD', {});
103
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
104
- });
105
- server.tool('figjam_get_selection', 'Retorna os nós atualmente selecionados no Figma.', {}, async () => {
106
- const result = await sendToPlugin('GET_SELECTION', {});
107
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
108
- });
109
- server.tool('figjam_create_section', 'Cria uma seção nomeada no FigJam para agrupar conteúdo.', {
110
- name: z.string().describe('Nome da seção'),
111
- x: z.number().optional().describe('Posição X'),
112
- y: z.number().optional().describe('Posição Y'),
113
- width: z.number().default(900).describe('Largura em px'),
114
- height: z.number().default(700).describe('Altura em px'),
115
- }, async ({ name, x, y, width, height }) => {
116
- const result = await sendToPlugin('CREATE_SECTION', { name, x, y, width, height });
117
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
118
- });
119
- // ── Boot ──────────────────────────────────────────────────────────────────────
120
- function log(msg) {
121
- process.stderr.write(`[IngresseFlow] ${msg}\n`);
122
- }
123
- await startBridge();
124
- const transport = new StdioServerTransport();
125
- await server.connect(transport);