@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.
@@ -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
- }, async ({ method, url, headers, body }) => {
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 result = await sendEncryptedRequest('http_request', {
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
  {
@@ -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;
@@ -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 resolvedBody;
194
- if (typeof body === 'string') {
195
- resolvedBody = matched.resolveSecretsInBody ? resolvePlaceholders(body, matched.secrets) : body;
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 if (body !== null && body !== undefined) {
198
- const serialized = JSON.stringify(body);
199
- resolvedBody = matched.resolveSecretsInBody
200
- ? resolvePlaceholders(serialized, matched.secrets)
201
- : serialized;
202
- if (!resolvedHeaders['content-type'] && !resolvedHeaders['Content-Type']) {
203
- resolvedHeaders['Content-Type'] = 'application/json';
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: resolvedBody,
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: '10mb' }));
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.6.0",
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",