@wolpertingerlabs/drawlatch 1.0.0-alpha.2 → 1.0.0-alpha.29

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.
Files changed (119) hide show
  1. package/CONNECTIONS.md +3 -0
  2. package/README.md +395 -465
  3. package/bin/drawlatch.js +1018 -49
  4. package/dist/auth/auth.d.ts +10 -0
  5. package/dist/auth/auth.js +146 -0
  6. package/dist/auth/env-writer.d.ts +6 -0
  7. package/dist/auth/env-writer.js +52 -0
  8. package/dist/auth/password.d.ts +8 -0
  9. package/dist/auth/password.js +28 -0
  10. package/dist/auth/sessions.d.ts +13 -0
  11. package/dist/auth/sessions.js +85 -0
  12. package/dist/cli/generate-keys.d.ts +4 -4
  13. package/dist/cli/generate-keys.js +30 -25
  14. package/dist/connections/{anthropic.json → ai/anthropic.json} +19 -1
  15. package/dist/connections/{devin.json → ai/devin.json} +7 -1
  16. package/dist/connections/{google-ai.json → ai/google-ai.json} +7 -1
  17. package/dist/connections/{openai.json → ai/openai.json} +7 -1
  18. package/dist/connections/{openrouter.json → ai/openrouter.json} +7 -1
  19. package/dist/connections/developer-tools/github.json +138 -0
  20. package/dist/connections/{hex.json → developer-tools/hex.json} +7 -1
  21. package/dist/connections/developer-tools/linear.json +75 -0
  22. package/dist/connections/{lichess.json → gaming/lichess.json} +7 -1
  23. package/dist/connections/messaging/agentmail.json +21 -0
  24. package/dist/connections/messaging/discord-bot.json +114 -0
  25. package/dist/connections/{discord-oauth.json → messaging/discord-oauth.json} +7 -1
  26. package/dist/connections/messaging/slack.json +75 -0
  27. package/dist/connections/messaging/telegram.json +66 -0
  28. package/dist/connections/{google.json → productivity/google.json} +7 -1
  29. package/dist/connections/productivity/notion.json +75 -0
  30. package/dist/connections/productivity/stripe.json +74 -0
  31. package/dist/connections/productivity/trello.json +113 -0
  32. package/dist/connections/{bluesky.json → social-media/bluesky.json} +41 -0
  33. package/dist/connections/social-media/mastodon.json +65 -0
  34. package/dist/connections/social-media/reddit.json +80 -0
  35. package/dist/connections/{twitch.json → social-media/twitch.json} +40 -0
  36. package/dist/connections/social-media/x.json +67 -0
  37. package/dist/mcp/server.js +544 -31
  38. package/dist/remote/admin-mutations.d.ts +37 -0
  39. package/dist/remote/admin-mutations.js +251 -0
  40. package/dist/remote/admin-types.d.ts +149 -0
  41. package/dist/remote/admin-types.js +11 -0
  42. package/dist/remote/admin.d.ts +37 -0
  43. package/dist/remote/admin.js +316 -0
  44. package/dist/remote/caller-bootstrap.d.ts +71 -0
  45. package/dist/remote/caller-bootstrap.js +141 -0
  46. package/dist/remote/ingestors/base-ingestor.d.ts +19 -3
  47. package/dist/remote/ingestors/base-ingestor.js +27 -6
  48. package/dist/remote/ingestors/discord/discord-gateway.d.ts +2 -2
  49. package/dist/remote/ingestors/discord/discord-gateway.js +29 -6
  50. package/dist/remote/ingestors/e2e/setup.d.ts +69 -0
  51. package/dist/remote/ingestors/e2e/setup.js +147 -0
  52. package/dist/remote/ingestors/manager.d.ts +110 -10
  53. package/dist/remote/ingestors/manager.js +449 -42
  54. package/dist/remote/ingestors/poll/poll-ingestor.d.ts +4 -2
  55. package/dist/remote/ingestors/poll/poll-ingestor.js +26 -5
  56. package/dist/remote/ingestors/registry.d.ts +2 -2
  57. package/dist/remote/ingestors/registry.js +2 -2
  58. package/dist/remote/ingestors/slack/socket-mode.d.ts +2 -2
  59. package/dist/remote/ingestors/slack/socket-mode.js +28 -6
  60. package/dist/remote/ingestors/types.d.ts +25 -0
  61. package/dist/remote/ingestors/webhook/base-webhook-ingestor.d.ts +46 -7
  62. package/dist/remote/ingestors/webhook/base-webhook-ingestor.js +115 -10
  63. package/dist/remote/ingestors/webhook/github-webhook-ingestor.d.ts +30 -0
  64. package/dist/remote/ingestors/webhook/github-webhook-ingestor.js +73 -2
  65. package/dist/remote/ingestors/webhook/lifecycle-types.d.ts +63 -0
  66. package/dist/remote/ingestors/webhook/lifecycle-types.js +12 -0
  67. package/dist/remote/ingestors/webhook/stripe-webhook-ingestor.js +2 -2
  68. package/dist/remote/ingestors/webhook/trello-webhook-ingestor.d.ts +17 -5
  69. package/dist/remote/ingestors/webhook/trello-webhook-ingestor.js +32 -26
  70. package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.d.ts +46 -0
  71. package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.js +261 -0
  72. package/dist/remote/server.d.ts +25 -33
  73. package/dist/remote/server.js +681 -195
  74. package/dist/remote/tool-dispatch.d.ts +84 -0
  75. package/dist/remote/tool-dispatch.js +910 -0
  76. package/dist/remote/triggers/rule-engine.d.ts +40 -0
  77. package/dist/remote/triggers/rule-engine.js +228 -0
  78. package/dist/remote/triggers/types.d.ts +69 -0
  79. package/dist/remote/triggers/types.js +10 -0
  80. package/dist/remote/tunnel-state.d.ts +14 -0
  81. package/dist/remote/tunnel-state.js +20 -0
  82. package/dist/remote/tunnel.d.ts +53 -0
  83. package/dist/remote/tunnel.js +149 -0
  84. package/dist/shared/config.d.ts +97 -25
  85. package/dist/shared/config.js +48 -35
  86. package/dist/shared/connections.d.ts +21 -5
  87. package/dist/shared/connections.js +56 -14
  88. package/dist/shared/crypto/index.d.ts +1 -0
  89. package/dist/shared/crypto/index.js +1 -0
  90. package/dist/shared/crypto/key-manager.d.ts +67 -0
  91. package/dist/shared/crypto/key-manager.js +152 -0
  92. package/dist/shared/env-utils.d.ts +42 -0
  93. package/dist/shared/env-utils.js +150 -0
  94. package/dist/shared/listener-config.d.ts +157 -0
  95. package/dist/shared/listener-config.js +10 -0
  96. package/dist/shared/migrations.d.ts +40 -0
  97. package/dist/shared/migrations.js +122 -0
  98. package/dist/shared/protocol/handshake.js +14 -1
  99. package/dist/shared/protocol/index.d.ts +2 -0
  100. package/dist/shared/protocol/index.js +2 -0
  101. package/dist/shared/protocol/sync-client.d.ts +52 -0
  102. package/dist/shared/protocol/sync-client.js +99 -0
  103. package/dist/shared/protocol/sync.d.ts +71 -0
  104. package/dist/shared/protocol/sync.js +176 -0
  105. package/frontend/dist/assets/index-D4k5QKBP.css +1 -0
  106. package/frontend/dist/assets/index-IzwnSOmu.js +267 -0
  107. package/frontend/dist/index.html +15 -0
  108. package/package.json +66 -13
  109. package/dist/connections/discord-bot.json +0 -24
  110. package/dist/connections/github.json +0 -25
  111. package/dist/connections/linear.json +0 -29
  112. package/dist/connections/mastodon.json +0 -25
  113. package/dist/connections/notion.json +0 -33
  114. package/dist/connections/reddit.json +0 -28
  115. package/dist/connections/slack.json +0 -23
  116. package/dist/connections/stripe.json +0 -25
  117. package/dist/connections/telegram.json +0 -26
  118. package/dist/connections/trello.json +0 -25
  119. package/dist/connections/x.json +0 -27
@@ -13,38 +13,81 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
15
  import crypto from 'node:crypto';
16
- import { loadProxyConfig } from '../shared/config.js';
17
- import { loadKeyBundle, loadPublicKeys, EncryptedChannel } from '../shared/crypto/index.js';
16
+ import fs from 'node:fs/promises';
17
+ import path from 'node:path';
18
+ import { loadProxyConfig, resolveCallerKeyAlias, getCallerKeysDir, getServerKeysDir, getProxyConfigPath, } from '../shared/config.js';
19
+ import { loadKeyBundle, loadPublicKeys, EncryptedChannel, extractPublicKeys, fingerprint, } from '../shared/crypto/index.js';
20
+ import { existsSync } from 'node:fs';
18
21
  import { HandshakeInitiator, } from '../shared/protocol/index.js';
19
22
  // ── State ──────────────────────────────────────────────────────────────────
20
23
  let channel = null;
21
24
  let remoteUrl;
22
25
  // ── Handshake ──────────────────────────────────────────────────────────────
23
26
  async function establishChannel() {
27
+ // Pre-flight validation
28
+ if (!existsSync(getProxyConfigPath())) {
29
+ throw new Error(`No proxy config found at ${getProxyConfigPath()}. Run: drawlatch init`);
30
+ }
24
31
  const config = loadProxyConfig();
25
32
  remoteUrl = config.remoteUrl;
26
- // Log which key identity is being used
27
- const envAlias = process.env.MCP_KEY_ALIAS?.trim();
28
- if (envAlias) {
29
- console.error(`[mcp-proxy] Using key alias from MCP_KEY_ALIAS: "${envAlias}"`);
33
+ // Resolve key paths from config dir + alias
34
+ const alias = resolveCallerKeyAlias();
35
+ const callerKeysDir = path.join(getCallerKeysDir(), alias);
36
+ const serverKeysDir = getServerKeysDir();
37
+ console.error(`[mcp-proxy] Using caller alias: "${alias}"`);
38
+ console.error(`[mcp-proxy] Caller keys dir: ${callerKeysDir}`);
39
+ // Validate key paths before attempting to load
40
+ if (!existsSync(callerKeysDir)) {
41
+ throw new Error(`Caller keys not found at ${callerKeysDir}. Run: drawlatch generate-keys caller ${alias}`);
42
+ }
43
+ if (!existsSync(serverKeysDir)) {
44
+ throw new Error(`Server public keys not found at ${serverKeysDir}. Run: drawlatch sync`);
45
+ }
46
+ let ownKeys, remotePub;
47
+ try {
48
+ ownKeys = loadKeyBundle(callerKeysDir);
30
49
  }
31
- else if (config.localKeyAlias) {
32
- console.error(`[mcp-proxy] Using key alias from config: "${config.localKeyAlias}"`);
50
+ catch (err) {
51
+ const msg = err instanceof Error ? err.message : String(err);
52
+ throw new Error(`Failed to load caller keys from ${callerKeysDir}: ${msg}. Run: drawlatch generate-keys caller ${alias}`);
53
+ }
54
+ try {
55
+ remotePub = loadPublicKeys(serverKeysDir);
56
+ }
57
+ catch (err) {
58
+ const msg = err instanceof Error ? err.message : String(err);
59
+ throw new Error(`Failed to load server public keys from ${serverKeysDir}: ${msg}. Run: drawlatch sync`);
33
60
  }
34
- console.error(`[mcp-proxy] Local keys dir: ${config.localKeysDir}`);
35
- const ownKeys = loadKeyBundle(config.localKeysDir);
36
- const remotePub = loadPublicKeys(config.remotePublicKeysDir);
37
61
  const initiator = new HandshakeInitiator(ownKeys, remotePub);
38
62
  // Step 1: Send HandshakeInit
39
63
  const initMsg = initiator.createInit();
40
- const initResp = await fetch(`${remoteUrl}/handshake/init`, {
41
- method: 'POST',
42
- headers: { 'Content-Type': 'application/json' },
43
- body: JSON.stringify(initMsg),
44
- signal: AbortSignal.timeout(config.connectTimeout),
45
- });
64
+ let initResp;
65
+ try {
66
+ initResp = await fetch(`${remoteUrl}/handshake/init`, {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify(initMsg),
70
+ signal: AbortSignal.timeout(config.connectTimeout),
71
+ });
72
+ }
73
+ catch (err) {
74
+ const msg = err instanceof Error ? err.message : String(err);
75
+ if (msg.includes('ECONNREFUSED')) {
76
+ throw new Error(`Remote server is not running at ${remoteUrl}. Start it with: drawlatch start`);
77
+ }
78
+ if ((err instanceof DOMException && err.name === 'AbortError') || msg.includes('timed out')) {
79
+ throw new Error(`Remote server at ${remoteUrl} is not responding (timed out after ${config.connectTimeout}ms)`);
80
+ }
81
+ throw new Error(`Failed to connect to remote server at ${remoteUrl}: ${msg}`);
82
+ }
46
83
  if (!initResp.ok) {
47
84
  const errText = await initResp.text();
85
+ const localFp = fingerprint(extractPublicKeys(ownKeys));
86
+ if (initResp.status === 401 || initResp.status === 403) {
87
+ console.error(`[mcp-proxy] Handshake rejected by server. Your key fingerprint: ${localFp}`);
88
+ console.error('[mcp-proxy] Ensure this fingerprint matches an authorized peer on the remote server.');
89
+ throw new Error(`Handshake rejected (${initResp.status}). Your fingerprint: ${localFp}. Check that public keys are correctly exchanged. See: drawlatch init`);
90
+ }
48
91
  throw new Error(`Handshake init failed: ${initResp.status} ${errText}`);
49
92
  }
50
93
  const reply = await initResp.json();
@@ -85,21 +128,42 @@ async function sendEncryptedRequest(toolName, toolInput) {
85
128
  };
86
129
  // Encrypt the entire request
87
130
  const encrypted = ch.encryptJSON(request);
88
- const resp = await fetch(`${remoteUrl}/request`, {
89
- method: 'POST',
90
- headers: {
91
- 'Content-Type': 'application/octet-stream',
92
- 'X-Session-Id': ch.sessionId,
93
- },
94
- body: new Uint8Array(encrypted),
95
- signal: AbortSignal.timeout(config.requestTimeout),
96
- });
131
+ let resp;
132
+ try {
133
+ resp = await fetch(`${remoteUrl}/request`, {
134
+ method: 'POST',
135
+ headers: {
136
+ 'Content-Type': 'application/octet-stream',
137
+ 'X-Session-Id': ch.sessionId,
138
+ },
139
+ body: new Uint8Array(encrypted),
140
+ signal: AbortSignal.timeout(config.requestTimeout),
141
+ });
142
+ }
143
+ catch (err) {
144
+ const msg = err instanceof Error ? err.message : String(err);
145
+ if (msg.includes('ECONNREFUSED')) {
146
+ channel = null;
147
+ throw new Error(`Remote server is not running at ${remoteUrl}. Start it with: drawlatch start`);
148
+ }
149
+ if ((err instanceof DOMException && err.name === 'AbortError') || msg.includes('timed out')) {
150
+ throw new Error(`Remote server at ${remoteUrl} is not responding (timed out after ${config.requestTimeout}ms)`);
151
+ }
152
+ channel = null;
153
+ throw new Error(`Failed to reach remote server at ${remoteUrl}: ${msg}`);
154
+ }
97
155
  if (!resp.ok) {
98
156
  // If session expired, re-establish
99
157
  if (resp.status === 401) {
100
158
  console.error('[mcp-proxy] Session expired, re-establishing...');
101
159
  channel = null;
102
- return sendEncryptedRequest(toolName, toolInput);
160
+ try {
161
+ return await sendEncryptedRequest(toolName, toolInput);
162
+ }
163
+ catch (reEstErr) {
164
+ const msg = reEstErr instanceof Error ? reEstErr.message : String(reEstErr);
165
+ throw new Error(`Session re-establishment failed: ${msg}`);
166
+ }
103
167
  }
104
168
  throw new Error(`Request failed: ${resp.status} ${await resp.text()}`);
105
169
  }
@@ -129,14 +193,54 @@ server.tool('secure_request', "Make an authenticated HTTP request through the en
129
193
  .optional()
130
194
  .describe('Request headers, may contain ${VAR} placeholders'),
131
195
  body: z.any().optional().describe('Request body (object for JSON, string for raw)'),
132
- }, async ({ method, url, headers, body }) => {
196
+ files: z
197
+ .array(z.object({
198
+ field: z.string().describe('Form field name (e.g., "files[0]", "file", "attachment")'),
199
+ path: z.string().describe('Absolute path to the file on the local filesystem'),
200
+ filename: z.string().describe('Filename to use in the upload'),
201
+ contentType: z.string().describe('MIME type (e.g., "image/png", "application/pdf")'),
202
+ }))
203
+ .optional()
204
+ .describe('File attachments for multipart/form-data uploads. When present, the request is sent as multipart with the JSON body as "payload_json". Use bodyFieldName to change the JSON part name.'),
205
+ bodyFieldName: z
206
+ .string()
207
+ .optional()
208
+ .describe('Form field name for the JSON body part in multipart requests (default: "payload_json"). Only used when files are present.'),
209
+ }, async ({ method, url, headers, body, files, bodyFieldName }) => {
133
210
  try {
134
- const result = await sendEncryptedRequest('http_request', {
211
+ const toolInput = {
135
212
  method,
136
213
  url,
137
214
  headers: headers ?? {},
138
215
  body,
139
- });
216
+ };
217
+ // Read and base64-encode files from the local filesystem before sending
218
+ // through the encrypted channel (remote server can't access local files)
219
+ if (files?.length) {
220
+ const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB per file
221
+ const encodedFiles = await Promise.all(files.map(async ({ field, path: filePath, filename, contentType }) => {
222
+ if (!path.isAbsolute(filePath)) {
223
+ throw new Error(`File path must be absolute: ${filePath}`);
224
+ }
225
+ let stat;
226
+ try {
227
+ stat = await fs.stat(filePath);
228
+ }
229
+ catch {
230
+ throw new Error(`File not found: ${filePath}`);
231
+ }
232
+ if (stat.size > MAX_FILE_SIZE) {
233
+ throw new Error(`File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB): ${filePath} — max ${MAX_FILE_SIZE / 1024 / 1024} MB`);
234
+ }
235
+ const buffer = await fs.readFile(filePath);
236
+ return { field, data: buffer.toString('base64'), filename, contentType };
237
+ }));
238
+ toolInput.files = encodedFiles;
239
+ if (bodyFieldName) {
240
+ toolInput.bodyFieldName = bodyFieldName;
241
+ }
242
+ }
243
+ const result = await sendEncryptedRequest('http_request', toolInput);
140
244
  return {
141
245
  content: [
142
246
  {
@@ -197,11 +301,16 @@ server.tool('poll_events', 'Poll for new events from ingestors (Discord messages
197
301
  .number()
198
302
  .optional()
199
303
  .describe('Return events with id > after_id. Omit or -1 for all buffered events.'),
200
- }, async ({ connection, after_id }) => {
304
+ instance_id: z
305
+ .string()
306
+ .optional()
307
+ .describe('Instance ID for multi-instance listeners (e.g., "project-board"). Omit for all instances.'),
308
+ }, async ({ connection, after_id, instance_id }) => {
201
309
  try {
202
310
  const result = await sendEncryptedRequest('poll_events', {
203
311
  connection,
204
312
  after_id,
313
+ instance_id,
205
314
  });
206
315
  return {
207
316
  content: [
@@ -245,11 +354,415 @@ server.tool('ingestor_status', 'Get the status of all active ingestors for this
245
354
  };
246
355
  }
247
356
  });
357
+ /**
358
+ * Test connectivity for a specific connection by running a pre-configured,
359
+ * non-destructive read-only request against its API.
360
+ */
361
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
362
+ server.tool('test_connection', 'Test connectivity to a specific connection by making a non-destructive read-only request. Verifies that API credentials are valid. Returns success/failure with status details.', {
363
+ connection: z.string().describe('Connection alias to test (e.g., "github", "discord-bot")'),
364
+ }, async ({ connection }) => {
365
+ try {
366
+ const result = await sendEncryptedRequest('test_connection', { connection });
367
+ return {
368
+ content: [
369
+ {
370
+ type: 'text',
371
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
372
+ },
373
+ ],
374
+ };
375
+ }
376
+ catch (err) {
377
+ const message = err instanceof Error ? err.message : String(err);
378
+ return {
379
+ content: [{ type: 'text', text: `Error: ${message}` }],
380
+ isError: true,
381
+ };
382
+ }
383
+ });
384
+ /**
385
+ * Test event listener / ingestor configuration for a specific connection.
386
+ * Verifies credentials and listener parameters are correct.
387
+ */
388
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
389
+ server.tool('test_ingestor', 'Test event listener configuration for a connection. Verifies credentials and listener parameters are correct without starting the full listener. Returns success/failure with details.', {
390
+ connection: z.string().describe('Connection alias to test listener for (e.g., "discord-bot")'),
391
+ }, async ({ connection }) => {
392
+ try {
393
+ const result = await sendEncryptedRequest('test_ingestor', { connection });
394
+ return {
395
+ content: [
396
+ {
397
+ type: 'text',
398
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
399
+ },
400
+ ],
401
+ };
402
+ }
403
+ catch (err) {
404
+ const message = err instanceof Error ? err.message : String(err);
405
+ return {
406
+ content: [{ type: 'text', text: `Error: ${message}` }],
407
+ isError: true,
408
+ };
409
+ }
410
+ });
411
+ /**
412
+ * List listener configuration schemas for all connections with configurable event listeners.
413
+ * Returns field schemas that UIs can use to render configuration forms.
414
+ */
415
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
416
+ server.tool('list_listener_configs', 'List configurable event listener schemas for all connections. Returns field definitions (type, label, options, defaults) that can be used to render configuration forms.', { _: z.string().optional().describe('unused') }, async () => {
417
+ try {
418
+ const result = await sendEncryptedRequest('list_listener_configs', {});
419
+ return {
420
+ content: [
421
+ {
422
+ type: 'text',
423
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
424
+ },
425
+ ],
426
+ };
427
+ }
428
+ catch (err) {
429
+ const message = err instanceof Error ? err.message : String(err);
430
+ return {
431
+ content: [{ type: 'text', text: `Error: ${message}` }],
432
+ isError: true,
433
+ };
434
+ }
435
+ });
436
+ /**
437
+ * Resolve dynamic options for a listener configuration field by fetching
438
+ * them from the external API (e.g., list of Trello boards, Discord guilds).
439
+ */
440
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
441
+ server.tool('resolve_listener_options', 'Fetch dynamic options for a listener configuration field. Some fields (like Trello boards) require an API call to populate their options list.', {
442
+ connection: z.string().describe('Connection alias (e.g., "trello")'),
443
+ paramKey: z.string().describe('The field key to resolve options for (e.g., "boardId")'),
444
+ }, async ({ connection, paramKey }) => {
445
+ try {
446
+ const result = await sendEncryptedRequest('resolve_listener_options', {
447
+ connection,
448
+ paramKey,
449
+ });
450
+ return {
451
+ content: [
452
+ {
453
+ type: 'text',
454
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
455
+ },
456
+ ],
457
+ };
458
+ }
459
+ catch (err) {
460
+ const message = err instanceof Error ? err.message : String(err);
461
+ return {
462
+ content: [{ type: 'text', text: `Error: ${message}` }],
463
+ isError: true,
464
+ };
465
+ }
466
+ });
467
+ /**
468
+ * Start, stop, or restart an event listener for a specific connection.
469
+ * Controls the lifecycle of ingestors at runtime without restarting the server.
470
+ */
471
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
472
+ server.tool('control_listener', 'Start, stop, or restart an event listener for a connection. Stopping a listener pauses event collection; starting resumes it. Use restart after configuration changes.', {
473
+ connection: z.string().describe('Connection alias (e.g., "discord-bot")'),
474
+ action: z.enum(['start', 'stop', 'restart']).describe('Lifecycle action to perform'),
475
+ instance_id: z
476
+ .string()
477
+ .optional()
478
+ .describe('Instance ID for multi-instance listeners. Omit to control all instances.'),
479
+ }, async ({ connection, action, instance_id }) => {
480
+ try {
481
+ const result = await sendEncryptedRequest('control_listener', {
482
+ connection,
483
+ action,
484
+ instance_id,
485
+ });
486
+ return {
487
+ content: [
488
+ {
489
+ type: 'text',
490
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
491
+ },
492
+ ],
493
+ };
494
+ }
495
+ catch (err) {
496
+ const message = err instanceof Error ? err.message : String(err);
497
+ return {
498
+ content: [{ type: 'text', text: `Error: ${message}` }],
499
+ isError: true,
500
+ };
501
+ }
502
+ });
503
+ /**
504
+ * Read current listener parameter overrides for a connection.
505
+ * Returns current values and schema defaults for populating config forms.
506
+ */
507
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
508
+ server.tool('get_listener_params', 'Read current listener parameter overrides for a connection. Returns current values and schema defaults. Use instance_id for multi-instance listeners.', {
509
+ connection: z.string().describe('Connection alias (e.g., "trello", "discord-bot")'),
510
+ instance_id: z
511
+ .string()
512
+ .optional()
513
+ .describe('Instance ID for multi-instance listeners. Omit for single-instance.'),
514
+ }, async ({ connection, instance_id }) => {
515
+ try {
516
+ const result = await sendEncryptedRequest('get_listener_params', { connection, instance_id });
517
+ return {
518
+ content: [
519
+ {
520
+ type: 'text',
521
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
522
+ },
523
+ ],
524
+ };
525
+ }
526
+ catch (err) {
527
+ const message = err instanceof Error ? err.message : String(err);
528
+ return {
529
+ content: [{ type: 'text', text: `Error: ${message}` }],
530
+ isError: true,
531
+ };
532
+ }
533
+ });
534
+ /**
535
+ * Add or edit listener parameter overrides for a connection.
536
+ * Merges params into existing config. Supports creating new multi-instance listeners.
537
+ */
538
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
539
+ server.tool('set_listener_params', 'Add or edit listener parameter overrides for a connection. Merges params into existing config. Set create_instance to true to create a new multi-instance listener.', {
540
+ connection: z.string().describe('Connection alias (e.g., "trello")'),
541
+ instance_id: z
542
+ .string()
543
+ .optional()
544
+ .describe('Instance ID for multi-instance listeners. Omit for single-instance.'),
545
+ params: z
546
+ .record(z.string(), z.unknown())
547
+ .describe('Key-value pairs to set. Keys must match listener config field keys.'),
548
+ create_instance: z
549
+ .boolean()
550
+ .optional()
551
+ .describe("Set to true to create a new instance if it doesn't exist."),
552
+ }, async ({ connection, instance_id, params, create_instance }) => {
553
+ try {
554
+ const result = await sendEncryptedRequest('set_listener_params', {
555
+ connection,
556
+ instance_id,
557
+ params,
558
+ create_instance,
559
+ });
560
+ return {
561
+ content: [
562
+ {
563
+ type: 'text',
564
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
565
+ },
566
+ ],
567
+ };
568
+ }
569
+ catch (err) {
570
+ const message = err instanceof Error ? err.message : String(err);
571
+ return {
572
+ content: [{ type: 'text', text: `Error: ${message}` }],
573
+ isError: true,
574
+ };
575
+ }
576
+ });
577
+ /**
578
+ * List all configured listener instances for a multi-instance connection.
579
+ * Returns every instance from config, including stopped/disabled ones.
580
+ */
581
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
582
+ server.tool('list_listener_instances', 'List all configured instances for a multi-instance listener connection. Returns every instance from config (including stopped/disabled ones), unlike ingestor_status which only shows running instances.', {
583
+ connection: z.string().describe('Connection alias (e.g., "trello")'),
584
+ }, async ({ connection }) => {
585
+ try {
586
+ const result = await sendEncryptedRequest('list_listener_instances', { connection });
587
+ return {
588
+ content: [
589
+ {
590
+ type: 'text',
591
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
592
+ },
593
+ ],
594
+ };
595
+ }
596
+ catch (err) {
597
+ const message = err instanceof Error ? err.message : String(err);
598
+ return {
599
+ content: [{ type: 'text', text: `Error: ${message}` }],
600
+ isError: true,
601
+ };
602
+ }
603
+ });
604
+ /**
605
+ * Delete a multi-instance listener instance.
606
+ * Stops the running ingestor if active and removes the instance from config.
607
+ */
608
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
609
+ server.tool('delete_listener_instance', 'Remove a multi-instance listener instance. Stops the running ingestor if active and removes the instance from config.', {
610
+ connection: z.string().describe('Connection alias (e.g., "trello")'),
611
+ instance_id: z
612
+ .string()
613
+ .describe('Instance ID to delete (required — only for multi-instance listeners).'),
614
+ }, async ({ connection, instance_id }) => {
615
+ try {
616
+ const result = await sendEncryptedRequest('delete_listener_instance', {
617
+ connection,
618
+ instance_id,
619
+ });
620
+ return {
621
+ content: [
622
+ {
623
+ type: 'text',
624
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
625
+ },
626
+ ],
627
+ };
628
+ }
629
+ catch (err) {
630
+ const message = err instanceof Error ? err.message : String(err);
631
+ return {
632
+ content: [{ type: 'text', text: `Error: ${message}` }],
633
+ isError: true,
634
+ };
635
+ }
636
+ });
637
+ /**
638
+ * List all available connection templates with caller-specific status.
639
+ * Returns which connections are enabled and which secrets are configured.
640
+ */
641
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
642
+ server.tool('list_connection_templates', 'List all available connection templates (built-in + custom). Returns template metadata, which connections are enabled for this caller, and which secrets are configured (boolean only, never values).', { _: z.string().optional().describe('unused') }, async () => {
643
+ try {
644
+ const result = await sendEncryptedRequest('list_connection_templates', {});
645
+ return {
646
+ content: [
647
+ {
648
+ type: 'text',
649
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
650
+ },
651
+ ],
652
+ };
653
+ }
654
+ catch (err) {
655
+ const message = err instanceof Error ? err.message : String(err);
656
+ return {
657
+ content: [{ type: 'text', text: `Error: ${message}` }],
658
+ isError: true,
659
+ };
660
+ }
661
+ });
662
+ /**
663
+ * Enable or disable a connection for the authenticated caller.
664
+ */
665
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
666
+ server.tool('set_connection_enabled', 'Enable or disable a connection for the authenticated caller. When disabling, any running ingestors for the connection are stopped.', {
667
+ connection: z.string().describe('Connection alias (e.g., "github", "discord-bot")'),
668
+ enabled: z.boolean().describe('Whether to enable (true) or disable (false) the connection'),
669
+ }, async ({ connection, enabled }) => {
670
+ try {
671
+ const result = await sendEncryptedRequest('set_connection_enabled', { connection, enabled });
672
+ return {
673
+ content: [
674
+ {
675
+ type: 'text',
676
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
677
+ },
678
+ ],
679
+ };
680
+ }
681
+ catch (err) {
682
+ const message = err instanceof Error ? err.message : String(err);
683
+ return {
684
+ content: [{ type: 'text', text: `Error: ${message}` }],
685
+ isError: true,
686
+ };
687
+ }
688
+ });
689
+ /**
690
+ * Set or delete secrets for the authenticated caller.
691
+ * Uses prefixed env vars for caller isolation. Never returns secret values.
692
+ */
693
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
694
+ server.tool('set_secrets', 'Set or delete secrets for the authenticated caller. Pass secret name → value pairs. Use empty string to delete a secret. Returns boolean status per secret (never values).', {
695
+ secrets: z
696
+ .record(z.string(), z.string())
697
+ .describe('Secret name → value pairs. Empty string deletes the secret.'),
698
+ }, async ({ secrets }) => {
699
+ try {
700
+ const result = await sendEncryptedRequest('set_secrets', { secrets });
701
+ return {
702
+ content: [
703
+ {
704
+ type: 'text',
705
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
706
+ },
707
+ ],
708
+ };
709
+ }
710
+ catch (err) {
711
+ const message = err instanceof Error ? err.message : String(err);
712
+ return {
713
+ content: [{ type: 'text', text: `Error: ${message}` }],
714
+ isError: true,
715
+ };
716
+ }
717
+ });
718
+ /**
719
+ * Check which secrets are set for the authenticated caller (never returns values).
720
+ */
721
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- registerTool is not available in this SDK version
722
+ server.tool('get_secret_status', 'Check which secrets are configured for a connection. Returns boolean status for each required and optional secret (never returns values).', {
723
+ connection: z.string().describe('Connection alias to check secrets for (e.g., "github")'),
724
+ }, async ({ connection }) => {
725
+ try {
726
+ const result = await sendEncryptedRequest('get_secret_status', { connection });
727
+ return {
728
+ content: [
729
+ {
730
+ type: 'text',
731
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
732
+ },
733
+ ],
734
+ };
735
+ }
736
+ catch (err) {
737
+ const message = err instanceof Error ? err.message : String(err);
738
+ return {
739
+ content: [{ type: 'text', text: `Error: ${message}` }],
740
+ isError: true,
741
+ };
742
+ }
743
+ });
248
744
  // ── Start ──────────────────────────────────────────────────────────────────
249
745
  async function main() {
250
746
  const transport = new StdioServerTransport();
251
747
  await server.connect(transport);
252
748
  console.error('[mcp-proxy] MCP Secure Proxy server started (stdio transport)');
749
+ // Non-blocking health check: advise user if the remote server is not reachable
750
+ try {
751
+ const config = loadProxyConfig();
752
+ const resp = await fetch(`${config.remoteUrl}/health`, {
753
+ signal: AbortSignal.timeout(3000),
754
+ });
755
+ if (resp.ok) {
756
+ console.error(`[mcp-proxy] Remote server reachable at ${config.remoteUrl}`);
757
+ }
758
+ else {
759
+ console.error(`[mcp-proxy] WARNING: Remote server at ${config.remoteUrl} returned ${resp.status}. Check server health.`);
760
+ }
761
+ }
762
+ catch {
763
+ const config = loadProxyConfig();
764
+ console.error(`[mcp-proxy] WARNING: Remote server at ${config.remoteUrl} is not reachable. Start it with: drawlatch start`);
765
+ }
253
766
  }
254
767
  main().catch((err) => {
255
768
  console.error('[mcp-proxy] Fatal error:', err);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Mutating admin API (item A), mounted behind `requireAuth` at `/api/admin/*`
3
+ * alongside the read-only endpoints in `admin.ts`.
4
+ *
5
+ * Every mutation reuses the canonical tool-dispatch (item D) so there is exactly
6
+ * one implementation of connection/secret/listener logic. Caller create/delete
7
+ * uses the programmatic bootstrap (item E).
8
+ *
9
+ * Security invariants (same as the read-only side):
10
+ * - Secret values are write-only: set via PUT, read back only as booleans.
11
+ * - No secret value, AES channel key, or process.env value is ever serialized.
12
+ * - After any config mutation, the daemon's live-reload path runs so ingestors
13
+ * and routes pick up the change (replacing callboard's restart banner).
14
+ */
15
+ import express from 'express';
16
+ import { type RemoteServerConfig, type ResolvedRoute } from '../shared/config.js';
17
+ import type { IngestorManager } from './ingestors/index.js';
18
+ export interface AdminMutationDeps {
19
+ ingestorManager: () => IngestorManager;
20
+ loadConfig: () => RemoteServerConfig;
21
+ /** Resolve the live routes for a caller (with secrets) for tool dispatch. */
22
+ resolveRoutesForCaller: (alias: string) => ResolvedRoute[];
23
+ /** Re-resolve routes for all active sessions of a caller (live reload). */
24
+ refreshCaller: (alias: string) => void;
25
+ /** Register/refresh the authorized peer for a newly-created caller. */
26
+ reloadPeer: (alias: string) => void;
27
+ /** Drop the authorized peer + active sessions for a deleted caller. */
28
+ removePeer: (alias: string) => void;
29
+ }
30
+ /**
31
+ * Mount the mutating routes onto an existing admin router.
32
+ *
33
+ * The router is already behind `requireAuth` (wired in server.ts), so these
34
+ * handlers can assume the request is authenticated.
35
+ */
36
+ export declare function mountAdminMutations(router: express.Router, deps: AdminMutationDeps): void;
37
+ //# sourceMappingURL=admin-mutations.d.ts.map