@wolpertingerlabs/drawlatch 1.0.0-alpha.6.0 → 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/server.d.ts +15 -0
- package/dist/remote/server.js +42 -14
- package/package.json +1 -1
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
|
{
|
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",
|