@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
@@ -0,0 +1,910 @@
1
+ /**
2
+ * Canonical MCP tool implementations — the single source of truth.
3
+ *
4
+ * These handlers implement every proxy/management tool drawlatch exposes:
5
+ * http_request, list_routes, poll_events, ingestor_status,
6
+ * test_connection, test_ingestor, control_listener,
7
+ * list_listener_configs, resolve_listener_options,
8
+ * get/set_listener_params, list/delete_listener_instance,
9
+ * list_connection_templates, set_connection_enabled,
10
+ * set_secrets, get_secret_status.
11
+ *
12
+ * Consumed by:
13
+ * - The remote secure server's `/request` dispatch (src/remote/server.ts)
14
+ * - The password-gated admin API (src/remote/admin-mutations.ts)
15
+ * - Any in-process host (e.g. callboard) that imports
16
+ * `@wolpertingerlabs/drawlatch/remote/tool-dispatch` instead of
17
+ * re-implementing a LocalProxy.
18
+ *
19
+ * Pure in the sense that handlers take `routes` + `context` as input rather
20
+ * than reading global session state. The only side effects are the outbound
21
+ * fetch() and config/.env writes (which mirror what the live daemon does).
22
+ */
23
+ import { loadRemoteConfig, saveRemoteConfig, resolvePlaceholders, } from '../shared/config.js';
24
+ import { listConnectionTemplates } from '../shared/connections.js';
25
+ import { isSecretSetForCaller, setCallerSecrets } from '../shared/env-utils.js';
26
+ // ── Endpoint matching ────────────────────────────────────────────────────────
27
+ export function isEndpointAllowed(url, patterns) {
28
+ if (patterns.length === 0)
29
+ return true; // no restrictions if empty
30
+ return patterns.some((pattern) => {
31
+ // Support simple glob patterns: * matches anything within a segment, ** matches across segments
32
+ const regex = new RegExp('^' +
33
+ pattern
34
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
35
+ .replace(/\*\*/g, '.__DOUBLE_STAR__.')
36
+ .replace(/\*/g, '[^/]*')
37
+ .replace(/\.__DOUBLE_STAR__\./g, '.*') +
38
+ '$');
39
+ return regex.test(url);
40
+ });
41
+ }
42
+ /**
43
+ * Find the first route whose allowedEndpoints match the given URL.
44
+ * Routes with empty allowedEndpoints match nothing.
45
+ */
46
+ export function matchRoute(url, routes) {
47
+ for (const route of routes) {
48
+ if (route.allowedEndpoints.length > 0 && isEndpointAllowed(url, route.allowedEndpoints)) {
49
+ return route;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ /**
55
+ * Core proxy request execution — route matching, secret injection, and fetch.
56
+ *
57
+ * Pure in the sense that it takes routes as input rather than reading global
58
+ * state. The only side effect is the outbound fetch().
59
+ */
60
+ export async function executeProxyRequest(input, routes) {
61
+ const { method, url, headers = {}, body, files, bodyFieldName } = input;
62
+ // Step 1: Find matching route — try raw URL first
63
+ let matched = matchRoute(url, routes);
64
+ let resolvedUrl = url;
65
+ if (matched) {
66
+ // Resolve URL placeholders using matched route's secrets
67
+ resolvedUrl = resolvePlaceholders(url, matched.secrets);
68
+ }
69
+ else {
70
+ // Try resolving URL with each route's secrets to find a match
71
+ for (const route of routes) {
72
+ if (route.allowedEndpoints.length === 0)
73
+ continue;
74
+ const candidateUrl = resolvePlaceholders(url, route.secrets);
75
+ if (isEndpointAllowed(candidateUrl, route.allowedEndpoints)) {
76
+ matched = route;
77
+ resolvedUrl = candidateUrl;
78
+ break;
79
+ }
80
+ }
81
+ }
82
+ if (!matched) {
83
+ throw new Error(`Endpoint not allowed: ${url}`);
84
+ }
85
+ // Step 2: Resolve client headers using matched route's secrets
86
+ const resolvedHeaders = {};
87
+ for (const [k, v] of Object.entries(headers)) {
88
+ resolvedHeaders[k] = resolvePlaceholders(v, matched.secrets);
89
+ }
90
+ // Step 3: Check for header conflicts — reject if client provides a header
91
+ // that conflicts with a route-level header (case-insensitive)
92
+ const routeHeaderKeys = new Set(Object.keys(matched.headers).map((k) => k.toLowerCase()));
93
+ for (const clientKey of Object.keys(resolvedHeaders)) {
94
+ if (routeHeaderKeys.has(clientKey.toLowerCase())) {
95
+ throw new Error(`Header conflict: client-provided header "${clientKey}" conflicts with a route-level header. Remove it from the request.`);
96
+ }
97
+ }
98
+ // Step 4: Merge route-level headers (they take effect after conflict check)
99
+ for (const [k, v] of Object.entries(matched.headers)) {
100
+ resolvedHeaders[k] = v;
101
+ }
102
+ // Step 5: Resolve body placeholders using matched route's secrets.
103
+ // Only when the route explicitly opts in via resolveSecretsInBody — prevents
104
+ // exfiltration of secrets by writing placeholder strings into API resources
105
+ // and reading them back.
106
+ let fetchBody;
107
+ if (files?.length) {
108
+ // ── Multipart mode: build FormData with file attachments ──
109
+ const form = new FormData();
110
+ // Add the JSON body as a named part (default: "payload_json" for Discord-style APIs)
111
+ if (body !== null && body !== undefined) {
112
+ const serialized = typeof body === 'string' ? body : JSON.stringify(body);
113
+ const resolvedPayload = matched.resolveSecretsInBody
114
+ ? resolvePlaceholders(serialized, matched.secrets)
115
+ : serialized;
116
+ form.append(bodyFieldName ?? 'payload_json', resolvedPayload);
117
+ }
118
+ // Attach each file from base64 data
119
+ for (const file of files) {
120
+ const buffer = Buffer.from(file.data, 'base64');
121
+ const blob = new Blob([buffer], { type: file.contentType });
122
+ form.append(file.field, blob, file.filename);
123
+ }
124
+ fetchBody = form;
125
+ // Let fetch auto-set Content-Type with the correct multipart boundary —
126
+ // remove any Content-Type that may have been set by route headers
127
+ delete resolvedHeaders['Content-Type'];
128
+ delete resolvedHeaders['content-type'];
129
+ }
130
+ else {
131
+ // ── Standard JSON/string body ──
132
+ if (typeof body === 'string') {
133
+ fetchBody = matched.resolveSecretsInBody ? resolvePlaceholders(body, matched.secrets) : body;
134
+ }
135
+ else if (body !== null && body !== undefined) {
136
+ const serialized = JSON.stringify(body);
137
+ fetchBody = matched.resolveSecretsInBody
138
+ ? resolvePlaceholders(serialized, matched.secrets)
139
+ : serialized;
140
+ if (!resolvedHeaders['content-type'] && !resolvedHeaders['Content-Type']) {
141
+ resolvedHeaders['Content-Type'] = 'application/json';
142
+ }
143
+ }
144
+ }
145
+ // Step 6: Final endpoint check on fully resolved URL
146
+ if (!isEndpointAllowed(resolvedUrl, matched.allowedEndpoints)) {
147
+ throw new Error(`Endpoint not allowed after resolution: ${url}`);
148
+ }
149
+ // Step 7: Make the actual HTTP request
150
+ const resp = await fetch(resolvedUrl, {
151
+ method,
152
+ headers: resolvedHeaders,
153
+ body: fetchBody,
154
+ });
155
+ const contentType = resp.headers.get('content-type') ?? '';
156
+ let responseBody;
157
+ if (contentType.includes('application/json')) {
158
+ responseBody = await resp.json();
159
+ }
160
+ else {
161
+ responseBody = await resp.text();
162
+ }
163
+ return {
164
+ status: resp.status,
165
+ statusText: resp.statusText,
166
+ headers: Object.fromEntries(resp.headers.entries()),
167
+ body: responseBody,
168
+ };
169
+ }
170
+ export const toolHandlers = {
171
+ /**
172
+ * Proxied HTTP request with route-scoped secret injection.
173
+ * Delegates to the extracted executeProxyRequest() function.
174
+ */
175
+ async http_request(input, routes, _context) {
176
+ return executeProxyRequest(input, routes);
177
+ },
178
+ /**
179
+ * List available routes with metadata, endpoint patterns, and secret names (not values).
180
+ * Provides full disclosure of available routes for the local agent.
181
+ */
182
+ list_routes(_input, routes, _context) {
183
+ const routeList = routes.map((route, index) => {
184
+ const info = { index };
185
+ if (route.alias)
186
+ info.alias = route.alias;
187
+ if (route.name)
188
+ info.name = route.name;
189
+ if (route.description)
190
+ info.description = route.description;
191
+ if (route.docsUrl)
192
+ info.docsUrl = route.docsUrl;
193
+ if (route.openApiUrl)
194
+ info.openApiUrl = route.openApiUrl;
195
+ if (route.stability)
196
+ info.stability = route.stability;
197
+ if (route.category)
198
+ info.category = route.category;
199
+ info.allowedEndpoints = route.allowedEndpoints;
200
+ info.secretNames = Object.keys(route.secrets);
201
+ info.autoHeaders = Object.keys(route.headers);
202
+ // Ingestor & testing metadata
203
+ info.hasTestConnection = route.testConnection !== undefined;
204
+ info.hasIngestor = route.ingestorConfig !== undefined;
205
+ if (route.ingestorConfig) {
206
+ info.ingestorType = route.ingestorConfig.type;
207
+ info.hasTestIngestor = route.testIngestor !== undefined && route.testIngestor !== null;
208
+ info.hasListenerConfig = route.listenerConfig !== undefined;
209
+ if (route.listenerConfig) {
210
+ info.listenerParamKeys = route.listenerConfig.fields.map((f) => f.key);
211
+ info.supportsMultiInstance = route.listenerConfig.supportsMultiInstance ?? false;
212
+ }
213
+ }
214
+ return info;
215
+ });
216
+ return Promise.resolve(routeList);
217
+ },
218
+ /**
219
+ * Poll for new events from ingestors (Discord Gateway, webhooks, pollers).
220
+ * Returns events since a cursor, optionally filtered by connection.
221
+ */
222
+ poll_events(input, _routes, context) {
223
+ const { connection, after_id, instance_id } = input;
224
+ const afterId = after_id ?? -1;
225
+ if (connection) {
226
+ return Promise.resolve(context.ingestorManager.getEvents(context.callerAlias, connection, afterId, instance_id));
227
+ }
228
+ return Promise.resolve(context.ingestorManager.getAllEvents(context.callerAlias, afterId));
229
+ },
230
+ /**
231
+ * Get the status of all active ingestors for this caller.
232
+ */
233
+ ingestor_status(_input, _routes, context) {
234
+ return Promise.resolve(context.ingestorManager.getStatuses(context.callerAlias));
235
+ },
236
+ /**
237
+ * Test a connection's API credentials by executing a pre-configured,
238
+ * non-destructive read-only request. Returns success/failure with status details.
239
+ */
240
+ async test_connection(input, routes, _context) {
241
+ const { connection } = input;
242
+ // Find the route matching this connection alias
243
+ const route = routes.find((r) => r.alias === connection);
244
+ if (!route) {
245
+ return { success: false, connection, error: `Unknown connection: ${connection}` };
246
+ }
247
+ if (!route.testConnection) {
248
+ return {
249
+ success: false,
250
+ connection,
251
+ supported: false,
252
+ error: 'This connection does not have a test configuration.',
253
+ };
254
+ }
255
+ const testConfig = route.testConnection;
256
+ const method = testConfig.method ?? 'GET';
257
+ const expectedStatus = testConfig.expectedStatus ?? [200];
258
+ try {
259
+ const result = await executeProxyRequest({
260
+ method,
261
+ url: testConfig.url,
262
+ headers: testConfig.headers,
263
+ body: testConfig.body,
264
+ }, routes);
265
+ const isSuccess = expectedStatus.includes(result.status);
266
+ return {
267
+ success: isSuccess,
268
+ connection,
269
+ status: result.status,
270
+ statusText: result.statusText,
271
+ description: testConfig.description,
272
+ ...(isSuccess
273
+ ? {}
274
+ : {
275
+ error: `Unexpected status ${result.status} (expected ${expectedStatus.join(' or ')})`,
276
+ }),
277
+ };
278
+ }
279
+ catch (err) {
280
+ return {
281
+ success: false,
282
+ connection,
283
+ description: testConfig.description,
284
+ error: err instanceof Error ? err.message : String(err),
285
+ };
286
+ }
287
+ },
288
+ /**
289
+ * Test an event listener / ingestor's configuration by running a lightweight
290
+ * verification appropriate to its type (auth check, secret check, poll check).
291
+ */
292
+ async test_ingestor(input, routes, _context) {
293
+ const { connection } = input;
294
+ const route = routes.find((r) => r.alias === connection);
295
+ if (!route) {
296
+ return { success: false, connection, error: `Unknown connection: ${connection}` };
297
+ }
298
+ if (!route.ingestorConfig) {
299
+ return {
300
+ success: false,
301
+ connection,
302
+ supported: false,
303
+ error: 'This connection does not have an event listener.',
304
+ };
305
+ }
306
+ // testIngestor is explicitly null = not testable
307
+ if (route.testIngestor === null) {
308
+ return {
309
+ success: false,
310
+ connection,
311
+ supported: false,
312
+ error: 'This event listener does not support testing.',
313
+ };
314
+ }
315
+ if (!route.testIngestor) {
316
+ return {
317
+ success: false,
318
+ connection,
319
+ supported: false,
320
+ error: 'This event listener does not have a test configuration.',
321
+ };
322
+ }
323
+ const testConfig = route.testIngestor;
324
+ try {
325
+ switch (testConfig.strategy) {
326
+ case 'webhook_verify': {
327
+ // Verify that all required secrets are present and non-empty
328
+ const missing = [];
329
+ for (const secretName of testConfig.requireSecrets ?? []) {
330
+ if (!route.secrets[secretName]) {
331
+ missing.push(secretName);
332
+ }
333
+ }
334
+ if (missing.length > 0) {
335
+ return {
336
+ success: false,
337
+ connection,
338
+ strategy: testConfig.strategy,
339
+ description: testConfig.description,
340
+ error: `Missing required secrets: ${missing.join(', ')}`,
341
+ };
342
+ }
343
+ return {
344
+ success: true,
345
+ connection,
346
+ strategy: testConfig.strategy,
347
+ description: testConfig.description,
348
+ message: 'All required webhook secrets are configured.',
349
+ };
350
+ }
351
+ case 'websocket_auth':
352
+ case 'http_request':
353
+ case 'poll_once': {
354
+ // Execute the test HTTP request
355
+ if (!testConfig.request) {
356
+ return {
357
+ success: false,
358
+ connection,
359
+ strategy: testConfig.strategy,
360
+ description: testConfig.description,
361
+ error: 'Test configuration missing request details.',
362
+ };
363
+ }
364
+ const method = testConfig.request.method ?? 'GET';
365
+ const expectedStatus = testConfig.request.expectedStatus ?? [200];
366
+ const result = await executeProxyRequest({
367
+ method,
368
+ url: testConfig.request.url,
369
+ headers: testConfig.request.headers,
370
+ body: testConfig.request.body,
371
+ }, routes);
372
+ const isSuccess = expectedStatus.includes(result.status);
373
+ return {
374
+ success: isSuccess,
375
+ connection,
376
+ strategy: testConfig.strategy,
377
+ status: result.status,
378
+ statusText: result.statusText,
379
+ description: testConfig.description,
380
+ ...(isSuccess
381
+ ? { message: 'Listener test passed.' }
382
+ : { error: `Unexpected status ${result.status}` }),
383
+ };
384
+ }
385
+ default:
386
+ return {
387
+ success: false,
388
+ connection,
389
+ error: `Unknown test strategy: ${String(testConfig.strategy)}`,
390
+ };
391
+ }
392
+ }
393
+ catch (err) {
394
+ return {
395
+ success: false,
396
+ connection,
397
+ strategy: testConfig.strategy,
398
+ description: testConfig.description,
399
+ error: err instanceof Error ? err.message : String(err),
400
+ };
401
+ }
402
+ },
403
+ /**
404
+ * List listener configuration schemas for all connections that have configurable
405
+ * event listeners. Returns the schema fields, current values, and metadata.
406
+ */
407
+ list_listener_configs(_input, routes, _context) {
408
+ const configs = routes
409
+ .filter((r) => r.listenerConfig)
410
+ .map((r) => ({
411
+ connection: r.alias,
412
+ name: r.listenerConfig.name,
413
+ description: r.listenerConfig.description,
414
+ fields: r.listenerConfig.fields,
415
+ ingestorType: r.ingestorConfig?.type,
416
+ supportsMultiInstance: r.listenerConfig.supportsMultiInstance ?? false,
417
+ instanceKeyField: r.listenerConfig.fields.find((f) => f.instanceKey)?.key,
418
+ }));
419
+ return Promise.resolve(configs);
420
+ },
421
+ /**
422
+ * Resolve dynamic options for a listener configuration field.
423
+ * Fetches options from the external API (e.g., list of Trello boards).
424
+ */
425
+ async resolve_listener_options(input, routes, _context) {
426
+ const { connection, paramKey } = input;
427
+ const route = routes.find((r) => r.alias === connection);
428
+ if (!route?.listenerConfig) {
429
+ return { success: false, error: `No listener config for connection: ${connection}` };
430
+ }
431
+ const field = route.listenerConfig.fields.find((f) => f.key === paramKey);
432
+ if (!field?.dynamicOptions) {
433
+ return { success: false, error: `No dynamic options for field: ${paramKey}` };
434
+ }
435
+ const { url, method = 'GET', body, responsePath, labelField, valueField, } = field.dynamicOptions;
436
+ try {
437
+ const result = await executeProxyRequest({ method, url, headers: {}, body }, routes);
438
+ // Navigate to the response path to find the items array
439
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- navigating unknown response shape
440
+ let items = result.body;
441
+ if (responsePath) {
442
+ for (const segment of responsePath.split('.')) {
443
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
444
+ items = items?.[segment];
445
+ }
446
+ }
447
+ if (!Array.isArray(items)) {
448
+ return { success: false, error: 'Response did not contain an array at the expected path.' };
449
+ }
450
+ const options = items.map((item) => ({
451
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
452
+ value: item[valueField],
453
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
454
+ label: item[labelField],
455
+ }));
456
+ return { success: true, connection, paramKey, options };
457
+ }
458
+ catch (err) {
459
+ return {
460
+ success: false,
461
+ connection,
462
+ paramKey,
463
+ error: err instanceof Error ? err.message : String(err),
464
+ };
465
+ }
466
+ },
467
+ /**
468
+ * Start, stop, or restart an event listener for a specific connection.
469
+ */
470
+ async control_listener(input, _routes, context) {
471
+ const { connection, action, instance_id } = input;
472
+ const mgr = context.ingestorManager;
473
+ try {
474
+ switch (action) {
475
+ case 'start':
476
+ return await mgr.startOne(context.callerAlias, connection, instance_id);
477
+ case 'stop':
478
+ return await mgr.stopOne(context.callerAlias, connection, instance_id);
479
+ case 'restart':
480
+ return await mgr.restartOne(context.callerAlias, connection, instance_id);
481
+ default:
482
+ return { success: false, error: `Unknown action: ${String(action)}` };
483
+ }
484
+ }
485
+ catch (err) {
486
+ return {
487
+ success: false,
488
+ connection,
489
+ action,
490
+ error: err instanceof Error ? err.message : String(err),
491
+ };
492
+ }
493
+ },
494
+ /**
495
+ * Read current listener parameter overrides for a connection.
496
+ * Returns current param values and schema defaults for form population.
497
+ */
498
+ get_listener_params(input, routes, context) {
499
+ const { connection, instance_id } = input;
500
+ // Find the route for this connection
501
+ const route = routes.find((r) => r.alias === connection);
502
+ if (!route) {
503
+ return Promise.resolve({
504
+ success: false,
505
+ connection,
506
+ error: `Unknown connection: ${connection}`,
507
+ });
508
+ }
509
+ if (!route.listenerConfig) {
510
+ return Promise.resolve({
511
+ success: false,
512
+ connection,
513
+ error: 'This connection does not have a listener configuration.',
514
+ });
515
+ }
516
+ // Build defaults from schema fields
517
+ const defaults = {};
518
+ for (const field of route.listenerConfig.fields) {
519
+ if (field.default !== undefined) {
520
+ defaults[field.key] = field.default;
521
+ }
522
+ }
523
+ // Load config to read current overrides
524
+ const config = loadRemoteConfig();
525
+ const callerConfig = config.callers[context.callerAlias];
526
+ if (!callerConfig) {
527
+ return Promise.resolve({
528
+ success: false,
529
+ connection,
530
+ error: `Caller not found: ${context.callerAlias}`,
531
+ });
532
+ }
533
+ let params = {};
534
+ if (instance_id) {
535
+ // Multi-instance: read from listenerInstances
536
+ const instanceOverrides = callerConfig.listenerInstances?.[connection]?.[instance_id];
537
+ if (!instanceOverrides) {
538
+ return Promise.resolve({
539
+ success: false,
540
+ connection,
541
+ instance_id,
542
+ error: `Instance not found: ${instance_id}`,
543
+ });
544
+ }
545
+ params = instanceOverrides.params ?? {};
546
+ }
547
+ else {
548
+ // Single-instance: read from ingestorOverrides
549
+ const overrides = callerConfig.ingestorOverrides?.[connection];
550
+ params = overrides?.params ?? {};
551
+ }
552
+ // When no instance_id is given on a multi-instance connection, include
553
+ // the list of configured instance IDs so callers can discover them
554
+ // without needing a separate list_listener_instances call.
555
+ let instances;
556
+ if (!instance_id && route.listenerConfig.supportsMultiInstance) {
557
+ const instanceMap = callerConfig.listenerInstances?.[connection] ?? {};
558
+ instances = Object.keys(instanceMap);
559
+ }
560
+ return Promise.resolve({
561
+ success: true,
562
+ connection,
563
+ ...(instance_id && { instance_id }),
564
+ params,
565
+ defaults,
566
+ ...(instances !== undefined && { instances }),
567
+ });
568
+ },
569
+ /**
570
+ * Add or edit listener parameter overrides for a connection.
571
+ * Merges params into existing config. For multi-instance, set create_instance
572
+ * to true to create a new instance if it doesn't exist.
573
+ * After saving, restarts the affected ingestor so new params take effect immediately.
574
+ */
575
+ async set_listener_params(input, routes, context) {
576
+ const { connection, instance_id, params, create_instance } = input;
577
+ // Find the route for this connection
578
+ const route = routes.find((r) => r.alias === connection);
579
+ if (!route) {
580
+ return { success: false, connection, error: `Unknown connection: ${connection}` };
581
+ }
582
+ if (!route.listenerConfig) {
583
+ return {
584
+ success: false,
585
+ connection,
586
+ error: 'This connection does not have a listener configuration.',
587
+ };
588
+ }
589
+ // Validate param keys against schema
590
+ const validKeys = new Set(route.listenerConfig.fields.map((f) => f.key));
591
+ const unknownKeys = Object.keys(params).filter((k) => !validKeys.has(k));
592
+ if (unknownKeys.length > 0) {
593
+ return {
594
+ success: false,
595
+ connection,
596
+ error: `Unknown parameter keys: ${unknownKeys.join(', ')}. Valid keys: ${Array.from(validKeys).join(', ')}`,
597
+ };
598
+ }
599
+ // Load config, modify, save
600
+ const config = loadRemoteConfig();
601
+ const callerConfig = config.callers[context.callerAlias];
602
+ if (!callerConfig) {
603
+ return {
604
+ success: false,
605
+ connection,
606
+ error: `Caller not found: ${context.callerAlias}`,
607
+ };
608
+ }
609
+ let mergedParams;
610
+ if (instance_id) {
611
+ // Multi-instance: write to listenerInstances
612
+ callerConfig.listenerInstances ??= {};
613
+ callerConfig.listenerInstances[connection] ??= {};
614
+ const existing = callerConfig.listenerInstances[connection][instance_id];
615
+ if (!existing && !create_instance) {
616
+ return {
617
+ success: false,
618
+ connection,
619
+ instance_id,
620
+ error: `Instance "${instance_id}" does not exist. Set create_instance to true to create it.`,
621
+ };
622
+ }
623
+ if (existing) {
624
+ existing.params = { ...(existing.params ?? {}), ...params };
625
+ mergedParams = existing.params;
626
+ }
627
+ else {
628
+ callerConfig.listenerInstances[connection][instance_id] = { params };
629
+ mergedParams = params;
630
+ }
631
+ }
632
+ else {
633
+ // Single-instance: write to ingestorOverrides
634
+ callerConfig.ingestorOverrides ??= {};
635
+ callerConfig.ingestorOverrides[connection] ??= {};
636
+ const overrides = callerConfig.ingestorOverrides[connection];
637
+ overrides.params = { ...(overrides.params ?? {}), ...params };
638
+ mergedParams = overrides.params;
639
+ }
640
+ saveRemoteConfig(config);
641
+ // Restart the affected ingestor so new params take effect immediately.
642
+ // This matches callboard's local-proxy behavior (which calls reinitialize()).
643
+ const mgr = context.ingestorManager;
644
+ if (mgr.has(context.callerAlias, connection, instance_id)) {
645
+ try {
646
+ await mgr.restartOne(context.callerAlias, connection, instance_id);
647
+ }
648
+ catch (err) {
649
+ // Config was saved successfully — log the restart failure but don't fail the operation
650
+ console.error(`[remote] Warning: params saved but failed to restart ingestor ${context.callerAlias}:${connection}${instance_id ? `:${instance_id}` : ''}:`, err);
651
+ return {
652
+ success: true,
653
+ connection,
654
+ ...(instance_id && { instance_id }),
655
+ params: mergedParams,
656
+ warning: 'Params saved but ingestor restart failed. Use control_listener to restart manually.',
657
+ };
658
+ }
659
+ }
660
+ return {
661
+ success: true,
662
+ connection,
663
+ ...(instance_id && { instance_id }),
664
+ params: mergedParams,
665
+ };
666
+ },
667
+ /**
668
+ * List all configured listener instances for a multi-instance connection.
669
+ * Returns every instance from config (including stopped/disabled ones),
670
+ * unlike ingestor_status which only shows running instances.
671
+ */
672
+ list_listener_instances(input, routes, context) {
673
+ const { connection } = input;
674
+ // Find the route for this connection
675
+ const route = routes.find((r) => r.alias === connection);
676
+ if (!route) {
677
+ return Promise.resolve({
678
+ success: false,
679
+ connection,
680
+ error: `Unknown connection: ${connection}`,
681
+ });
682
+ }
683
+ if (!route.listenerConfig?.supportsMultiInstance) {
684
+ return Promise.resolve({
685
+ success: false,
686
+ connection,
687
+ error: 'This connection does not support multi-instance listeners.',
688
+ });
689
+ }
690
+ // Read from config
691
+ const config = loadRemoteConfig();
692
+ const callerConfig = config.callers[context.callerAlias];
693
+ if (!callerConfig) {
694
+ return Promise.resolve({
695
+ success: false,
696
+ connection,
697
+ error: `Caller not found: ${context.callerAlias}`,
698
+ });
699
+ }
700
+ const instanceMap = callerConfig.listenerInstances?.[connection] ?? {};
701
+ const instances = Object.entries(instanceMap).map(([instanceId, overrides]) => ({
702
+ instanceId,
703
+ disabled: overrides.disabled ?? false,
704
+ params: overrides.params ?? {},
705
+ }));
706
+ return Promise.resolve({
707
+ success: true,
708
+ connection,
709
+ instances,
710
+ });
711
+ },
712
+ /**
713
+ * Delete a multi-instance listener instance.
714
+ * Removes from config and stops the running ingestor if active.
715
+ */
716
+ async delete_listener_instance(input, _routes, context) {
717
+ const { connection, instance_id } = input;
718
+ // Load config
719
+ const config = loadRemoteConfig();
720
+ const callerConfig = config.callers[context.callerAlias];
721
+ if (!callerConfig) {
722
+ return {
723
+ success: false,
724
+ connection,
725
+ instance_id,
726
+ error: `Caller not found: ${context.callerAlias}`,
727
+ };
728
+ }
729
+ const instances = callerConfig.listenerInstances?.[connection];
730
+ if (!instances || !(instance_id in instances)) {
731
+ return {
732
+ success: false,
733
+ connection,
734
+ instance_id,
735
+ error: `Instance "${instance_id}" not found for connection "${connection}".`,
736
+ };
737
+ }
738
+ // Stop the running ingestor if active
739
+ const mgr = context.ingestorManager;
740
+ if (mgr.has(context.callerAlias, connection, instance_id)) {
741
+ try {
742
+ await mgr.stopOne(context.callerAlias, connection, instance_id, { permanent: true });
743
+ }
744
+ catch (err) {
745
+ // Log but don't fail the delete
746
+ console.error(`[remote] Warning: failed to stop ingestor ${context.callerAlias}:${connection}:${instance_id}:`, err);
747
+ }
748
+ }
749
+ // Remove from config
750
+ const { [instance_id]: _removed, ...remainingInstances } = instances;
751
+ // Clean up empty maps
752
+ if (Object.keys(remainingInstances).length === 0) {
753
+ if (callerConfig.listenerInstances) {
754
+ const { [connection]: _removedConn, ...remainingConns } = callerConfig.listenerInstances;
755
+ if (Object.keys(remainingConns).length === 0) {
756
+ delete callerConfig.listenerInstances;
757
+ }
758
+ else {
759
+ callerConfig.listenerInstances = remainingConns;
760
+ }
761
+ }
762
+ }
763
+ else {
764
+ callerConfig.listenerInstances[connection] = remainingInstances;
765
+ }
766
+ saveRemoteConfig(config);
767
+ return { success: true, connection, instance_id };
768
+ },
769
+ // ── Config management tools ─────────────────────────────────────────────
770
+ /**
771
+ * List all available connection templates with caller-specific status.
772
+ * Returns template metadata, which ones the caller has enabled,
773
+ * and which secrets are configured.
774
+ */
775
+ list_connection_templates: (_input, _routes, context) => {
776
+ const config = loadRemoteConfig();
777
+ const caller = config.callers[context.callerAlias];
778
+ const enabledSet = new Set(caller.connections);
779
+ const templates = listConnectionTemplates();
780
+ return templates.map((t) => {
781
+ const callerEnv = caller.env;
782
+ const requiredSecretsSet = {};
783
+ for (const s of t.requiredSecrets) {
784
+ requiredSecretsSet[s] = isSecretSetForCaller(s, context.callerAlias, callerEnv);
785
+ }
786
+ const optionalSecretsSet = {};
787
+ for (const s of t.optionalSecrets) {
788
+ optionalSecretsSet[s] = isSecretSetForCaller(s, context.callerAlias, callerEnv);
789
+ }
790
+ return {
791
+ alias: t.alias,
792
+ name: t.name,
793
+ ...(t.description !== undefined && { description: t.description }),
794
+ ...(t.docsUrl !== undefined && { docsUrl: t.docsUrl }),
795
+ ...(t.openApiUrl !== undefined && { openApiUrl: t.openApiUrl }),
796
+ stability: t.stability,
797
+ category: t.category,
798
+ requiredSecrets: t.requiredSecrets,
799
+ optionalSecrets: t.optionalSecrets,
800
+ hasIngestor: t.hasIngestor,
801
+ ...(t.ingestorType !== undefined && { ingestorType: t.ingestorType }),
802
+ allowedEndpoints: t.allowedEndpoints,
803
+ enabled: enabledSet.has(t.alias),
804
+ requiredSecretsSet,
805
+ optionalSecretsSet,
806
+ };
807
+ });
808
+ },
809
+ /**
810
+ * Enable or disable a connection for the authenticated caller.
811
+ */
812
+ set_connection_enabled: async (input, _routes, context) => {
813
+ const connection = input.connection;
814
+ const enabled = input.enabled;
815
+ if (!connection || typeof enabled !== 'boolean') {
816
+ throw new Error('Required: connection (string) and enabled (boolean)');
817
+ }
818
+ const config = loadRemoteConfig();
819
+ const caller = config.callers[context.callerAlias];
820
+ // Verify the connection template exists (built-in or custom connector)
821
+ const connectorAliases = new Set((config.connectors ?? []).map((c) => c.alias).filter(Boolean));
822
+ const templateAliases = new Set(listConnectionTemplates().map((t) => t.alias));
823
+ if (!connectorAliases.has(connection) && !templateAliases.has(connection)) {
824
+ throw new Error(`Unknown connection: ${connection}`);
825
+ }
826
+ const connectionSet = new Set(caller.connections);
827
+ if (enabled) {
828
+ connectionSet.add(connection);
829
+ }
830
+ else {
831
+ connectionSet.delete(connection);
832
+ // Stop any running ingestors for this connection
833
+ const ingestorManager = context.ingestorManager;
834
+ try {
835
+ await ingestorManager.stopOne(context.callerAlias, connection);
836
+ }
837
+ catch {
838
+ // Ingestor may not be running — that's fine
839
+ }
840
+ }
841
+ caller.connections = [...connectionSet];
842
+ saveRemoteConfig(config);
843
+ // Invalidate cached resolved routes so connection changes take effect immediately
844
+ context.refreshRoutes();
845
+ return { success: true, connection, enabled };
846
+ },
847
+ /**
848
+ * Set or delete secrets for the authenticated caller.
849
+ * Uses prefixed env vars to prevent cross-caller collisions.
850
+ */
851
+ set_secrets: (input, _routes, context) => {
852
+ const secrets = input.secrets;
853
+ if (!secrets || typeof secrets !== 'object') {
854
+ throw new Error('Required: secrets (Record<string, string>)');
855
+ }
856
+ const config = loadRemoteConfig();
857
+ const { config: updatedConfig, status } = setCallerSecrets(secrets, context.callerAlias, config);
858
+ saveRemoteConfig(updatedConfig);
859
+ // Invalidate cached resolved routes so new secrets take effect immediately
860
+ context.refreshRoutes();
861
+ return { success: true, secretsSet: status };
862
+ },
863
+ /**
864
+ * Check which secrets are set for the authenticated caller (never returns values).
865
+ */
866
+ get_secret_status: (input, _routes, context) => {
867
+ const connection = input.connection;
868
+ if (!connection) {
869
+ throw new Error('Required: connection (string)');
870
+ }
871
+ // Find the connection template
872
+ const templates = listConnectionTemplates();
873
+ const template = templates.find((t) => t.alias === connection);
874
+ if (!template) {
875
+ throw new Error(`Unknown connection: ${connection}`);
876
+ }
877
+ const config = loadRemoteConfig();
878
+ const caller = config.callers[context.callerAlias];
879
+ const callerEnv = caller.env;
880
+ const requiredSecretsSet = {};
881
+ for (const s of template.requiredSecrets) {
882
+ requiredSecretsSet[s] = isSecretSetForCaller(s, context.callerAlias, callerEnv);
883
+ }
884
+ const optionalSecretsSet = {};
885
+ for (const s of template.optionalSecrets) {
886
+ optionalSecretsSet[s] = isSecretSetForCaller(s, context.callerAlias, callerEnv);
887
+ }
888
+ return {
889
+ success: true,
890
+ connection,
891
+ requiredSecretsSet,
892
+ optionalSecretsSet,
893
+ };
894
+ },
895
+ };
896
+ /**
897
+ * Dispatch a single tool call by name. Throws on unknown tool.
898
+ *
899
+ * The canonical entry point for in-process hosts (admin API, callboard) that
900
+ * want to invoke a tool without going through the encrypted `/request` path.
901
+ */
902
+ export async function dispatchTool(toolName, input, routes, context) {
903
+ const handler = toolHandlers[toolName];
904
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime validation for untrusted input
905
+ if (!handler) {
906
+ throw new Error(`Unknown tool: ${toolName}`);
907
+ }
908
+ return handler(input, routes, context);
909
+ }
910
+ //# sourceMappingURL=tool-dispatch.js.map