@wolpertingerlabs/drawlatch 1.0.0-alpha.5.2 → 1.0.0-alpha.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mcp/server.js +49 -3
- package/dist/remote/ingestors/base-ingestor.d.ts +3 -0
- package/dist/remote/ingestors/base-ingestor.js +14 -4
- package/dist/remote/ingestors/discord/discord-gateway.js +24 -1
- package/dist/remote/ingestors/slack/socket-mode.js +23 -1
- package/dist/remote/server.d.ts +15 -0
- package/dist/remote/server.js +42 -14
- package/package.json +3 -3
package/dist/mcp/server.js
CHANGED
|
@@ -13,6 +13,8 @@ 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 fs from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
16
18
|
import { loadProxyConfig } from '../shared/config.js';
|
|
17
19
|
import { loadKeyBundle, loadPublicKeys, EncryptedChannel } from '../shared/crypto/index.js';
|
|
18
20
|
import { HandshakeInitiator, } from '../shared/protocol/index.js';
|
|
@@ -129,14 +131,58 @@ server.tool('secure_request', "Make an authenticated HTTP request through the en
|
|
|
129
131
|
.optional()
|
|
130
132
|
.describe('Request headers, may contain ${VAR} placeholders'),
|
|
131
133
|
body: z.any().optional().describe('Request body (object for JSON, string for raw)'),
|
|
132
|
-
|
|
134
|
+
files: z
|
|
135
|
+
.array(z.object({
|
|
136
|
+
field: z
|
|
137
|
+
.string()
|
|
138
|
+
.describe('Form field name (e.g., "files[0]", "file", "attachment")'),
|
|
139
|
+
path: z.string().describe('Absolute path to the file on the local filesystem'),
|
|
140
|
+
filename: z.string().describe('Filename to use in the upload'),
|
|
141
|
+
contentType: z
|
|
142
|
+
.string()
|
|
143
|
+
.describe('MIME type (e.g., "image/png", "application/pdf")'),
|
|
144
|
+
}))
|
|
145
|
+
.optional()
|
|
146
|
+
.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.'),
|
|
147
|
+
bodyFieldName: z
|
|
148
|
+
.string()
|
|
149
|
+
.optional()
|
|
150
|
+
.describe('Form field name for the JSON body part in multipart requests (default: "payload_json"). Only used when files are present.'),
|
|
151
|
+
}, async ({ method, url, headers, body, files, bodyFieldName }) => {
|
|
133
152
|
try {
|
|
134
|
-
const
|
|
153
|
+
const toolInput = {
|
|
135
154
|
method,
|
|
136
155
|
url,
|
|
137
156
|
headers: headers ?? {},
|
|
138
157
|
body,
|
|
139
|
-
}
|
|
158
|
+
};
|
|
159
|
+
// Read and base64-encode files from the local filesystem before sending
|
|
160
|
+
// through the encrypted channel (remote server can't access local files)
|
|
161
|
+
if (files?.length) {
|
|
162
|
+
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB per file
|
|
163
|
+
const encodedFiles = await Promise.all(files.map(async ({ field, path: filePath, filename, contentType }) => {
|
|
164
|
+
if (!path.isAbsolute(filePath)) {
|
|
165
|
+
throw new Error(`File path must be absolute: ${filePath}`);
|
|
166
|
+
}
|
|
167
|
+
let stat;
|
|
168
|
+
try {
|
|
169
|
+
stat = await fs.stat(filePath);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
throw new Error(`File not found: ${filePath}`);
|
|
173
|
+
}
|
|
174
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
175
|
+
throw new Error(`File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB): ${filePath} — max ${MAX_FILE_SIZE / 1024 / 1024} MB`);
|
|
176
|
+
}
|
|
177
|
+
const buffer = await fs.readFile(filePath);
|
|
178
|
+
return { field, data: buffer.toString('base64'), filename, contentType };
|
|
179
|
+
}));
|
|
180
|
+
toolInput.files = encodedFiles;
|
|
181
|
+
if (bodyFieldName) {
|
|
182
|
+
toolInput.bodyFieldName = bodyFieldName;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const result = await sendEncryptedRequest('http_request', toolInput);
|
|
140
186
|
return {
|
|
141
187
|
content: [
|
|
142
188
|
{
|
|
@@ -27,6 +27,9 @@ export declare abstract class BaseIngestor extends EventEmitter {
|
|
|
27
27
|
protected counter: number;
|
|
28
28
|
protected lastEventAt: string | null;
|
|
29
29
|
protected errorMessage?: string;
|
|
30
|
+
/** Per-instance epoch for event ID generation.
|
|
31
|
+
* Each instance claims a unique epoch so IDs never collide across restarts. */
|
|
32
|
+
private readonly bootEpoch;
|
|
30
33
|
/** Recently seen idempotency keys for deduplication. */
|
|
31
34
|
private readonly seenKeys;
|
|
32
35
|
constructor(
|
|
@@ -18,15 +18,19 @@ const log = createLogger('ingestor');
|
|
|
18
18
|
* When exceeded, the oldest half is pruned. */
|
|
19
19
|
const MAX_SEEN_KEYS = 2000;
|
|
20
20
|
/**
|
|
21
|
-
* Epoch-based event IDs that are monotonically increasing across
|
|
21
|
+
* Epoch-based event IDs that are monotonically increasing across restarts.
|
|
22
22
|
* Format: `bootEpochSeconds * 1_000_000 + counter`.
|
|
23
23
|
*
|
|
24
24
|
* Uses seconds (not milliseconds) so the product stays within Number.MAX_SAFE_INTEGER.
|
|
25
25
|
* This prevents clients with a stale `after_id` cursor from missing events
|
|
26
26
|
* after a reboot — new IDs will always be higher than pre-reboot IDs
|
|
27
|
-
* (assuming <1M events per
|
|
27
|
+
* (assuming <1M events per instance and >1s between restarts).
|
|
28
|
+
*
|
|
29
|
+
* Uses a monotonically increasing module-level epoch so that in-process restarts
|
|
30
|
+
* (e.g., LocalProxy.reinitialize()) also produce strictly increasing IDs.
|
|
31
|
+
* Each new BaseIngestor instance claims the next available epoch.
|
|
28
32
|
*/
|
|
29
|
-
|
|
33
|
+
let nextEpoch = Math.floor(Date.now() / 1000);
|
|
30
34
|
const ID_MULTIPLIER = 1_000_000;
|
|
31
35
|
export class BaseIngestor extends EventEmitter {
|
|
32
36
|
connectionAlias;
|
|
@@ -38,6 +42,9 @@ export class BaseIngestor extends EventEmitter {
|
|
|
38
42
|
counter = 0;
|
|
39
43
|
lastEventAt = null;
|
|
40
44
|
errorMessage;
|
|
45
|
+
/** Per-instance epoch for event ID generation.
|
|
46
|
+
* Each instance claims a unique epoch so IDs never collide across restarts. */
|
|
47
|
+
bootEpoch;
|
|
41
48
|
/** Recently seen idempotency keys for deduplication. */
|
|
42
49
|
seenKeys = new Set();
|
|
43
50
|
constructor(
|
|
@@ -59,6 +66,9 @@ export class BaseIngestor extends EventEmitter {
|
|
|
59
66
|
this.secrets = secrets;
|
|
60
67
|
this.instanceId = instanceId;
|
|
61
68
|
this.buffer = new RingBuffer(bufferSize);
|
|
69
|
+
// Claim a unique epoch for this instance — ensures event IDs are strictly
|
|
70
|
+
// increasing even when ingestors are restarted in-process.
|
|
71
|
+
this.bootEpoch = nextEpoch++;
|
|
62
72
|
}
|
|
63
73
|
/**
|
|
64
74
|
* Push a new event into the ring buffer.
|
|
@@ -77,7 +87,7 @@ export class BaseIngestor extends EventEmitter {
|
|
|
77
87
|
return;
|
|
78
88
|
}
|
|
79
89
|
const now = new Date();
|
|
80
|
-
const id =
|
|
90
|
+
const id = this.bootEpoch * ID_MULTIPLIER + this.counter++;
|
|
81
91
|
const key = idempotencyKey ?? `${this.connectionAlias}:${crypto.randomUUID()}`;
|
|
82
92
|
const event = {
|
|
83
93
|
id,
|
|
@@ -60,8 +60,31 @@ export class DiscordGatewayIngestor extends BaseIngestor {
|
|
|
60
60
|
this.state = 'stopped';
|
|
61
61
|
this.clearAllTimers();
|
|
62
62
|
if (this.ws) {
|
|
63
|
-
this.ws
|
|
63
|
+
const ws = this.ws;
|
|
64
64
|
this.ws = null;
|
|
65
|
+
// Wait for the WebSocket to fully close before resolving.
|
|
66
|
+
// This prevents a race where the old connection is still alive when a new
|
|
67
|
+
// ingestor starts. For Discord, this avoids the old gateway session
|
|
68
|
+
// lingering while a new IDENTIFY is sent (which Discord may reject
|
|
69
|
+
// if it sees two sessions for the same bot token).
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const onClose = () => {
|
|
72
|
+
ws.removeEventListener('close', onClose);
|
|
73
|
+
resolve();
|
|
74
|
+
};
|
|
75
|
+
if (ws.readyState === WebSocket.CLOSED) {
|
|
76
|
+
resolve();
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
ws.addEventListener('close', onClose);
|
|
80
|
+
ws.close(1000, 'Shutting down');
|
|
81
|
+
// Safety timeout — don't block forever if close event never fires
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
ws.removeEventListener('close', onClose);
|
|
84
|
+
resolve();
|
|
85
|
+
}, 5000);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
65
88
|
}
|
|
66
89
|
return Promise.resolve();
|
|
67
90
|
}
|
|
@@ -47,8 +47,30 @@ export class SlackSocketModeIngestor extends BaseIngestor {
|
|
|
47
47
|
this.state = 'stopped';
|
|
48
48
|
this.clearReconnectTimer();
|
|
49
49
|
if (this.ws) {
|
|
50
|
-
this.ws
|
|
50
|
+
const ws = this.ws;
|
|
51
51
|
this.ws = null;
|
|
52
|
+
// Wait for the WebSocket to fully close before resolving.
|
|
53
|
+
// This prevents a race where the old connection is still alive when a new
|
|
54
|
+
// ingestor starts, causing Slack to distribute events to the dying
|
|
55
|
+
// connection (where nobody processes them).
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const onClose = () => {
|
|
58
|
+
ws.removeEventListener('close', onClose);
|
|
59
|
+
resolve();
|
|
60
|
+
};
|
|
61
|
+
if (ws.readyState === WebSocket.CLOSED) {
|
|
62
|
+
resolve();
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
ws.addEventListener('close', onClose);
|
|
66
|
+
ws.close(1000, 'Shutting down');
|
|
67
|
+
// Safety timeout — don't block forever if close event never fires
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
ws.removeEventListener('close', onClose);
|
|
70
|
+
resolve();
|
|
71
|
+
}, 5000);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
52
74
|
}
|
|
53
75
|
return Promise.resolve();
|
|
54
76
|
}
|
package/dist/remote/server.d.ts
CHANGED
|
@@ -57,11 +57,26 @@ export declare function cleanupSessions(sessionsMap: Map<string, Pick<Session, '
|
|
|
57
57
|
expiredSessions: string[];
|
|
58
58
|
expiredHandshakes: string[];
|
|
59
59
|
};
|
|
60
|
+
/** A file attachment transmitted as base64 data through the encrypted channel. */
|
|
61
|
+
export interface FileAttachment {
|
|
62
|
+
/** Form field name (e.g., "files[0]", "file", "attachment") */
|
|
63
|
+
field: string;
|
|
64
|
+
/** Base64-encoded file content */
|
|
65
|
+
data: string;
|
|
66
|
+
/** Filename for the upload */
|
|
67
|
+
filename: string;
|
|
68
|
+
/** MIME type (e.g., "image/png", "application/pdf") */
|
|
69
|
+
contentType: string;
|
|
70
|
+
}
|
|
60
71
|
export interface ProxyRequestInput {
|
|
61
72
|
method: string;
|
|
62
73
|
url: string;
|
|
63
74
|
headers?: Record<string, string>;
|
|
64
75
|
body?: unknown;
|
|
76
|
+
/** File attachments — triggers multipart/form-data encoding */
|
|
77
|
+
files?: FileAttachment[];
|
|
78
|
+
/** Form field name for the JSON body part (default: "payload_json") */
|
|
79
|
+
bodyFieldName?: string;
|
|
65
80
|
}
|
|
66
81
|
export interface ProxyRequestResult {
|
|
67
82
|
status: number;
|
package/dist/remote/server.js
CHANGED
|
@@ -145,7 +145,7 @@ setInterval(() => {
|
|
|
145
145
|
* The only side effect is the outbound fetch().
|
|
146
146
|
*/
|
|
147
147
|
export async function executeProxyRequest(input, routes) {
|
|
148
|
-
const { method, url, headers = {}, body } = input;
|
|
148
|
+
const { method, url, headers = {}, body, files, bodyFieldName } = input;
|
|
149
149
|
// Step 1: Find matching route — try raw URL first
|
|
150
150
|
let matched = matchRoute(url, routes);
|
|
151
151
|
let resolvedUrl = url;
|
|
@@ -190,17 +190,45 @@ export async function executeProxyRequest(input, routes) {
|
|
|
190
190
|
// Only when the route explicitly opts in via resolveSecretsInBody — prevents
|
|
191
191
|
// exfiltration of secrets by writing placeholder strings into API resources
|
|
192
192
|
// and reading them back.
|
|
193
|
-
let
|
|
194
|
-
if (
|
|
195
|
-
|
|
193
|
+
let fetchBody;
|
|
194
|
+
if (files?.length) {
|
|
195
|
+
// ── Multipart mode: build FormData with file attachments ──
|
|
196
|
+
const form = new FormData();
|
|
197
|
+
// Add the JSON body as a named part (default: "payload_json" for Discord-style APIs)
|
|
198
|
+
if (body !== null && body !== undefined) {
|
|
199
|
+
const serialized = typeof body === 'string' ? body : JSON.stringify(body);
|
|
200
|
+
const resolvedPayload = matched.resolveSecretsInBody
|
|
201
|
+
? resolvePlaceholders(serialized, matched.secrets)
|
|
202
|
+
: serialized;
|
|
203
|
+
form.append(bodyFieldName ?? 'payload_json', resolvedPayload);
|
|
204
|
+
}
|
|
205
|
+
// Attach each file from base64 data
|
|
206
|
+
for (const file of files) {
|
|
207
|
+
const buffer = Buffer.from(file.data, 'base64');
|
|
208
|
+
const blob = new Blob([buffer], { type: file.contentType });
|
|
209
|
+
form.append(file.field, blob, file.filename);
|
|
210
|
+
}
|
|
211
|
+
fetchBody = form;
|
|
212
|
+
// Let fetch auto-set Content-Type with the correct multipart boundary —
|
|
213
|
+
// remove any Content-Type that may have been set by route headers
|
|
214
|
+
delete resolvedHeaders['Content-Type'];
|
|
215
|
+
delete resolvedHeaders['content-type'];
|
|
196
216
|
}
|
|
197
|
-
else
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
217
|
+
else {
|
|
218
|
+
// ── Standard JSON/string body ──
|
|
219
|
+
if (typeof body === 'string') {
|
|
220
|
+
fetchBody = matched.resolveSecretsInBody
|
|
221
|
+
? resolvePlaceholders(body, matched.secrets)
|
|
222
|
+
: body;
|
|
223
|
+
}
|
|
224
|
+
else if (body !== null && body !== undefined) {
|
|
225
|
+
const serialized = JSON.stringify(body);
|
|
226
|
+
fetchBody = matched.resolveSecretsInBody
|
|
227
|
+
? resolvePlaceholders(serialized, matched.secrets)
|
|
228
|
+
: serialized;
|
|
229
|
+
if (!resolvedHeaders['content-type'] && !resolvedHeaders['Content-Type']) {
|
|
230
|
+
resolvedHeaders['Content-Type'] = 'application/json';
|
|
231
|
+
}
|
|
204
232
|
}
|
|
205
233
|
}
|
|
206
234
|
// Step 6: Final endpoint check on fully resolved URL
|
|
@@ -211,7 +239,7 @@ export async function executeProxyRequest(input, routes) {
|
|
|
211
239
|
const resp = await fetch(resolvedUrl, {
|
|
212
240
|
method,
|
|
213
241
|
headers: resolvedHeaders,
|
|
214
|
-
body:
|
|
242
|
+
body: fetchBody,
|
|
215
243
|
});
|
|
216
244
|
const contentType = resp.headers.get('content-type') ?? '';
|
|
217
245
|
let responseBody;
|
|
@@ -833,8 +861,8 @@ export function createApp(options = {}) {
|
|
|
833
861
|
const app = express();
|
|
834
862
|
// Parse JSON for handshake endpoints
|
|
835
863
|
app.use('/handshake', express.json());
|
|
836
|
-
// Raw buffer for encrypted request endpoint
|
|
837
|
-
app.use('/request', express.raw({ type: 'application/octet-stream', limit: '
|
|
864
|
+
// Raw buffer for encrypted request endpoint (50 MB to accommodate base64-encoded file uploads)
|
|
865
|
+
app.use('/request', express.raw({ type: 'application/octet-stream', limit: '50mb' }));
|
|
838
866
|
// Raw buffer for webhook endpoints (needed for signature verification)
|
|
839
867
|
app.use('/webhooks', express.raw({ type: 'application/json', limit: '1mb' }));
|
|
840
868
|
const config = options.config ?? loadRemoteConfig();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wolpertingerlabs/drawlatch",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.7.0",
|
|
4
4
|
"description": "Encrypted MCP proxy with mutual authentication. Local MCP server forwards requests through an encrypted channel to a remote secrets-holding server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/mcp/server.js",
|
|
@@ -55,8 +55,8 @@
|
|
|
55
55
|
"scripts": {
|
|
56
56
|
"build": "tsc && rm -rf dist/connections && cp -r src/connections dist/connections",
|
|
57
57
|
"prepare": "npm run build",
|
|
58
|
-
"dev:remote": "tsx src/remote/server.ts",
|
|
59
|
-
"dev:mcp": "tsx src/mcp/server.ts",
|
|
58
|
+
"dev:remote": "MCP_CONFIG_DIR=~/.drawlatch-dev tsx src/remote/server.ts",
|
|
59
|
+
"dev:mcp": "MCP_CONFIG_DIR=~/.drawlatch-dev tsx src/mcp/server.ts",
|
|
60
60
|
"generate-keys": "tsx src/cli/generate-keys.ts",
|
|
61
61
|
"start:remote": "NODE_ENV=production node dist/remote/server.js",
|
|
62
62
|
"start:mcp": "node dist/mcp/server.js",
|