@elliotding/ai-agent-mcp 0.1.20 → 0.1.22

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.
@@ -315,15 +315,69 @@ export class HTTPServer {
315
315
  // See config/index.ts for details.
316
316
  const basePath = config.http?.basePath ?? '';
317
317
  const publicOrigin = config.http?.publicOrigin ?? `http://127.0.0.1:${config.http?.port ?? 3000}`;
318
-
319
- // We pass only the path to SSEServerTransport so its internal URL
320
- // parsing produces a clean relative URL. After connect() we re-send
321
- // an absolute endpoint event so Cursor (which may sit behind a proxy
322
- // with a different origin than its SSE connection) uses the correct
323
- // public address for all subsequent JSON-RPC POST messages.
324
318
  const messagePath = `${basePath}/message`;
325
- const transport = new SSEServerTransport(messagePath, reply.raw);
319
+
320
+ // The MCP SDK SSEServerTransport.start() emits an `endpoint` SSE event
321
+ // whose data is a *relative* path (pathname + ?sessionId=...), stripping
322
+ // the origin. When deployed behind nginx, Cursor resolves this relative
323
+ // path against whatever origin it used to open the SSE connection, which
324
+ // may differ from our public API origin. The result is that GetPrompt /
325
+ // tools/call POST requests go to the wrong address and never arrive.
326
+ //
327
+ // Fix: intercept the raw response stream's write() method. When the SDK
328
+ // emits the relative endpoint event we replace it on-the-fly with the
329
+ // full absolute URL so Cursor always uses the correct public address.
330
+ // Only ONE endpoint event is ever written to the wire this way.
331
+ const rawRes = reply.raw;
332
+ const originalWrite = rawRes.write.bind(rawRes);
333
+ (rawRes as NodeJS.WritableStream & { write: typeof originalWrite }).write = (
334
+ chunk: unknown,
335
+ encodingOrCb?: unknown,
336
+ cb?: unknown,
337
+ ): boolean => {
338
+ if (typeof chunk === 'string' && chunk.startsWith('event: endpoint\ndata:')) {
339
+ // The SDK wrote a relative endpoint event — replace with absolute URL.
340
+ // We know the sessionId from transport.sessionId (read after construction).
341
+ // Use a placeholder here; replaced below once we have the transport.
342
+ // (This interceptor is set before connect(), so the write happens during
343
+ // connect() → transport.start().)
344
+ chunk = chunk.replace(
345
+ /^(event: endpoint\ndata:).*/,
346
+ `$1 ${publicOrigin}${messagePath}?sessionId=__SESSION_ID__`,
347
+ );
348
+ }
349
+ if (typeof encodingOrCb === 'function') {
350
+ return originalWrite(chunk as string, encodingOrCb as () => void);
351
+ }
352
+ if (typeof cb === 'function') {
353
+ return originalWrite(chunk as string, encodingOrCb as BufferEncoding, cb as () => void);
354
+ }
355
+ return originalWrite(chunk as string);
356
+ };
357
+
358
+ const transport = new SSEServerTransport(messagePath, rawRes);
326
359
  const sdkSessionId = transport.sessionId;
360
+
361
+ // Now patch the placeholder with the real sessionId that the SDK assigned.
362
+ // The write interceptor is still active during connect() → start(), so we
363
+ // swap it out for a version that knows the actual sessionId.
364
+ (rawRes as NodeJS.WritableStream & { write: typeof originalWrite }).write = (
365
+ chunk: unknown,
366
+ encodingOrCb?: unknown,
367
+ cb?: unknown,
368
+ ): boolean => {
369
+ if (typeof chunk === 'string' && chunk.startsWith('event: endpoint\ndata:')) {
370
+ chunk = `event: endpoint\ndata: ${publicOrigin}${messagePath}?sessionId=${sdkSessionId}\n\n`;
371
+ }
372
+ if (typeof encodingOrCb === 'function') {
373
+ return originalWrite(chunk as string, encodingOrCb as () => void);
374
+ }
375
+ if (typeof cb === 'function') {
376
+ return originalWrite(chunk as string, encodingOrCb as BufferEncoding, cb as () => void);
377
+ }
378
+ return originalWrite(chunk as string);
379
+ };
380
+
327
381
  this.sseTransports.set(sdkSessionId, transport);
328
382
 
329
383
  transport.onclose = () => {
@@ -337,29 +391,21 @@ export class HTTPServer {
337
391
  logger.warn({ sdkSessionId, error: err.message }, 'SSE transport error');
338
392
  };
339
393
 
340
- // Create a per-connection MCP Server and connect it to the transport.
341
- // connect() calls transport.start() which writes the initial (relative)
342
- // endpoint SSE event. We then immediately overwrite it with an absolute
343
- // URL so Cursor uses the correct public address regardless of how nginx
344
- // forwards the SSE connection.
345
394
  const mcpServer = this.createMcpServer(
346
395
  request.user?.userId,
347
396
  request.user?.email,
348
397
  request.user?.groups,
349
398
  token,
350
399
  );
400
+
401
+ // connect() calls transport.start() which triggers the intercepted write()
402
+ // above — emitting exactly ONE absolute endpoint event to the wire.
351
403
  await mcpServer.connect(transport);
352
404
 
353
- // Re-send the endpoint event with the full absolute URL.
354
- // This overwrites the relative-path event emitted by the SDK and
355
- // ensures Cursor POSTs subsequent JSON-RPC messages (prompts/get,
356
- // tools/call, etc.) to the correct externally-reachable address.
357
405
  const absoluteMessageUrl = `${publicOrigin}${messagePath}?sessionId=${sdkSessionId}`;
358
- reply.raw.write(`event: endpoint\ndata: ${absoluteMessageUrl}\n\n`);
359
-
360
406
  logger.info(
361
407
  { sdkSessionId, absoluteMessageUrl, publicOrigin },
362
- 'SSE stream established — absolute endpoint event sent',
408
+ 'SSE stream established — absolute endpoint URL intercepted and sent',
363
409
  );
364
410
 
365
411
  // Handle client disconnect
@@ -580,12 +580,15 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
580
580
  }
581
581
 
582
582
  // ── Rule resource ─────────────────────────────────────────────────────
583
- // Return write_file actions; the AI writes the files locally.
584
- // Each action carries content_hash so the AI can skip the write when
585
- // the local file already has identical content.
583
+ // Return write_file actions; the AI Agent executes them on the user's
584
+ // LOCAL machine. Each action carries content_hash (SHA-256) so the AI
585
+ // can compare against the existing local file and skip the write when
586
+ // the digests match — avoiding unnecessary disk I/O. If the local file
587
+ // is missing or has different content, the AI writes it unconditionally,
588
+ // which also recovers files that were accidentally deleted by the user.
586
589
  if (sub.type === 'rule') {
587
590
  const typeDir = getCursorTypeDirForClient(sub.type);
588
- const writeActions: string[] = [];
591
+ const writeActions: Array<{ destPath: string; hash: string; contentLength: number }> = [];
589
592
 
590
593
  for (const file of resourceFiles) {
591
594
  const normalised = path.normalize(file.path);
@@ -594,13 +597,14 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
594
597
  continue;
595
598
  }
596
599
  const destPath = `${typeDir}/${normalised}`;
600
+ const contentHash = sha256(file.content);
597
601
  localActions.push({
598
602
  action: 'write_file',
599
603
  path: destPath,
600
604
  content: file.content,
601
- content_hash: sha256(file.content),
605
+ content_hash: contentHash,
602
606
  });
603
- writeActions.push(destPath);
607
+ writeActions.push({ destPath, hash: contentHash, contentLength: file.content.length });
604
608
  }
605
609
 
606
610
  logger.info(
@@ -609,9 +613,10 @@ export async function syncResources(params: unknown): Promise<ToolResult<SyncRes
609
613
  resourceName: sub.name,
610
614
  typeDir,
611
615
  fileCount: writeActions.length,
612
- destPaths: writeActions,
616
+ files: writeActions,
617
+ clientSideNote: 'AI will compare content_hash against local file SHA-256; write is skipped if equal, executed if different or file missing',
613
618
  },
614
- 'sync_resources: Rule — write_file actions queued for AI',
619
+ 'sync_resources: Rule — write_file actions queued for AI (client-side hash comparison)',
615
620
  );
616
621
 
617
622
  tally.synced++;