@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.
- package/CONNECTIONS.md +3 -0
- package/README.md +395 -465
- package/bin/drawlatch.js +1018 -49
- package/dist/auth/auth.d.ts +10 -0
- package/dist/auth/auth.js +146 -0
- package/dist/auth/env-writer.d.ts +6 -0
- package/dist/auth/env-writer.js +52 -0
- package/dist/auth/password.d.ts +8 -0
- package/dist/auth/password.js +28 -0
- package/dist/auth/sessions.d.ts +13 -0
- package/dist/auth/sessions.js +85 -0
- package/dist/cli/generate-keys.d.ts +4 -4
- package/dist/cli/generate-keys.js +30 -25
- package/dist/connections/{anthropic.json → ai/anthropic.json} +19 -1
- package/dist/connections/{devin.json → ai/devin.json} +7 -1
- package/dist/connections/{google-ai.json → ai/google-ai.json} +7 -1
- package/dist/connections/{openai.json → ai/openai.json} +7 -1
- package/dist/connections/{openrouter.json → ai/openrouter.json} +7 -1
- package/dist/connections/developer-tools/github.json +138 -0
- package/dist/connections/{hex.json → developer-tools/hex.json} +7 -1
- package/dist/connections/developer-tools/linear.json +75 -0
- package/dist/connections/{lichess.json → gaming/lichess.json} +7 -1
- package/dist/connections/messaging/agentmail.json +21 -0
- package/dist/connections/messaging/discord-bot.json +114 -0
- package/dist/connections/{discord-oauth.json → messaging/discord-oauth.json} +7 -1
- package/dist/connections/messaging/slack.json +75 -0
- package/dist/connections/messaging/telegram.json +66 -0
- package/dist/connections/{google.json → productivity/google.json} +7 -1
- package/dist/connections/productivity/notion.json +75 -0
- package/dist/connections/productivity/stripe.json +74 -0
- package/dist/connections/productivity/trello.json +113 -0
- package/dist/connections/{bluesky.json → social-media/bluesky.json} +41 -0
- package/dist/connections/social-media/mastodon.json +65 -0
- package/dist/connections/social-media/reddit.json +80 -0
- package/dist/connections/{twitch.json → social-media/twitch.json} +40 -0
- package/dist/connections/social-media/x.json +67 -0
- package/dist/mcp/server.js +544 -31
- package/dist/remote/admin-mutations.d.ts +37 -0
- package/dist/remote/admin-mutations.js +251 -0
- package/dist/remote/admin-types.d.ts +149 -0
- package/dist/remote/admin-types.js +11 -0
- package/dist/remote/admin.d.ts +37 -0
- package/dist/remote/admin.js +316 -0
- package/dist/remote/caller-bootstrap.d.ts +71 -0
- package/dist/remote/caller-bootstrap.js +141 -0
- package/dist/remote/ingestors/base-ingestor.d.ts +19 -3
- package/dist/remote/ingestors/base-ingestor.js +27 -6
- package/dist/remote/ingestors/discord/discord-gateway.d.ts +2 -2
- package/dist/remote/ingestors/discord/discord-gateway.js +29 -6
- package/dist/remote/ingestors/e2e/setup.d.ts +69 -0
- package/dist/remote/ingestors/e2e/setup.js +147 -0
- package/dist/remote/ingestors/manager.d.ts +110 -10
- package/dist/remote/ingestors/manager.js +449 -42
- package/dist/remote/ingestors/poll/poll-ingestor.d.ts +4 -2
- package/dist/remote/ingestors/poll/poll-ingestor.js +26 -5
- package/dist/remote/ingestors/registry.d.ts +2 -2
- package/dist/remote/ingestors/registry.js +2 -2
- package/dist/remote/ingestors/slack/socket-mode.d.ts +2 -2
- package/dist/remote/ingestors/slack/socket-mode.js +28 -6
- package/dist/remote/ingestors/types.d.ts +25 -0
- package/dist/remote/ingestors/webhook/base-webhook-ingestor.d.ts +46 -7
- package/dist/remote/ingestors/webhook/base-webhook-ingestor.js +115 -10
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.d.ts +30 -0
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.js +73 -2
- package/dist/remote/ingestors/webhook/lifecycle-types.d.ts +63 -0
- package/dist/remote/ingestors/webhook/lifecycle-types.js +12 -0
- package/dist/remote/ingestors/webhook/stripe-webhook-ingestor.js +2 -2
- package/dist/remote/ingestors/webhook/trello-webhook-ingestor.d.ts +17 -5
- package/dist/remote/ingestors/webhook/trello-webhook-ingestor.js +32 -26
- package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.d.ts +46 -0
- package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.js +261 -0
- package/dist/remote/server.d.ts +25 -33
- package/dist/remote/server.js +681 -195
- package/dist/remote/tool-dispatch.d.ts +84 -0
- package/dist/remote/tool-dispatch.js +910 -0
- package/dist/remote/triggers/rule-engine.d.ts +40 -0
- package/dist/remote/triggers/rule-engine.js +228 -0
- package/dist/remote/triggers/types.d.ts +69 -0
- package/dist/remote/triggers/types.js +10 -0
- package/dist/remote/tunnel-state.d.ts +14 -0
- package/dist/remote/tunnel-state.js +20 -0
- package/dist/remote/tunnel.d.ts +53 -0
- package/dist/remote/tunnel.js +149 -0
- package/dist/shared/config.d.ts +97 -25
- package/dist/shared/config.js +48 -35
- package/dist/shared/connections.d.ts +21 -5
- package/dist/shared/connections.js +56 -14
- package/dist/shared/crypto/index.d.ts +1 -0
- package/dist/shared/crypto/index.js +1 -0
- package/dist/shared/crypto/key-manager.d.ts +67 -0
- package/dist/shared/crypto/key-manager.js +152 -0
- package/dist/shared/env-utils.d.ts +42 -0
- package/dist/shared/env-utils.js +150 -0
- package/dist/shared/listener-config.d.ts +157 -0
- package/dist/shared/listener-config.js +10 -0
- package/dist/shared/migrations.d.ts +40 -0
- package/dist/shared/migrations.js +122 -0
- package/dist/shared/protocol/handshake.js +14 -1
- package/dist/shared/protocol/index.d.ts +2 -0
- package/dist/shared/protocol/index.js +2 -0
- package/dist/shared/protocol/sync-client.d.ts +52 -0
- package/dist/shared/protocol/sync-client.js +99 -0
- package/dist/shared/protocol/sync.d.ts +71 -0
- package/dist/shared/protocol/sync.js +176 -0
- package/frontend/dist/assets/index-D4k5QKBP.css +1 -0
- package/frontend/dist/assets/index-IzwnSOmu.js +267 -0
- package/frontend/dist/index.html +15 -0
- package/package.json +66 -13
- package/dist/connections/discord-bot.json +0 -24
- package/dist/connections/github.json +0 -25
- package/dist/connections/linear.json +0 -29
- package/dist/connections/mastodon.json +0 -25
- package/dist/connections/notion.json +0 -33
- package/dist/connections/reddit.json +0 -28
- package/dist/connections/slack.json +0 -23
- package/dist/connections/stripe.json +0 -25
- package/dist/connections/telegram.json +0 -26
- package/dist/connections/trello.json +0 -25
- package/dist/connections/x.json +0 -27
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
17
|
-
import
|
|
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
|
-
//
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|