multicorn-shield 1.11.0 → 1.11.1
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/CHANGELOG.md +7 -0
- package/dist/multicorn-proxy.js +100 -12
- package/dist/multicorn-shield.js +95 -11
- package/dist/server.js +3499 -0
- package/dist/shield-extension.js +1 -1
- package/package.json +2 -2
package/dist/server.js
ADDED
|
@@ -0,0 +1,3499 @@
|
|
|
1
|
+
import { createServer } from 'http';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { isIP } from 'net';
|
|
5
|
+
import { readFileSync } from 'fs';
|
|
6
|
+
import { join, dirname } from 'path';
|
|
7
|
+
|
|
8
|
+
// ../multicorn-proxy/src/server.ts
|
|
9
|
+
|
|
10
|
+
// ../multicorn-proxy/node_modules/.pnpm/multicorn-shield@file+..+multicorn-shield/node_modules/multicorn-shield/dist/proxy.js
|
|
11
|
+
var BLOCKED_ERROR_CODE = -32e3;
|
|
12
|
+
var INTERNAL_ERROR_CODE = -32002;
|
|
13
|
+
var SERVICE_UNREACHABLE_ERROR_CODE = -32003;
|
|
14
|
+
var AUTH_ERROR_CODE = -32004;
|
|
15
|
+
function parseJsonRpcLine(line) {
|
|
16
|
+
const trimmed = line.trim();
|
|
17
|
+
if (trimmed.length === 0) return null;
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(trimmed);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return isJsonRpcRequest(parsed) ? parsed : null;
|
|
25
|
+
}
|
|
26
|
+
function extractToolCallParams(request) {
|
|
27
|
+
if (request.method !== "tools/call") return null;
|
|
28
|
+
if (typeof request.params !== "object" || request.params === null) return null;
|
|
29
|
+
const params = request.params;
|
|
30
|
+
const name = params["name"];
|
|
31
|
+
const args = params["arguments"];
|
|
32
|
+
if (typeof name !== "string") return null;
|
|
33
|
+
if (typeof args !== "object" || args === null) return null;
|
|
34
|
+
return { name, arguments: args };
|
|
35
|
+
}
|
|
36
|
+
function buildBlockedResponse(id, service, _permissionLevel, dashboardUrl) {
|
|
37
|
+
const displayService = capitalize(service);
|
|
38
|
+
const message = `Action blocked by Shield
|
|
39
|
+
|
|
40
|
+
This agent cannot use ${displayService}.
|
|
41
|
+
|
|
42
|
+
Configure permissions: ${dashboardUrl}`;
|
|
43
|
+
return {
|
|
44
|
+
jsonrpc: "2.0",
|
|
45
|
+
id,
|
|
46
|
+
error: {
|
|
47
|
+
code: BLOCKED_ERROR_CODE,
|
|
48
|
+
message
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function buildInternalErrorResponse(id) {
|
|
53
|
+
const message = "Action blocked: Shield encountered an internal error and cannot verify permissions. Check proxy logs for details.";
|
|
54
|
+
return {
|
|
55
|
+
jsonrpc: "2.0",
|
|
56
|
+
id,
|
|
57
|
+
error: {
|
|
58
|
+
code: INTERNAL_ERROR_CODE,
|
|
59
|
+
message
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function buildServiceUnreachableResponse(id, dashboardUrl) {
|
|
64
|
+
const message = `Action blocked: Shield cannot verify permissions (service unreachable). Configure offline behaviour at ${dashboardUrl}`;
|
|
65
|
+
return {
|
|
66
|
+
jsonrpc: "2.0",
|
|
67
|
+
id,
|
|
68
|
+
error: {
|
|
69
|
+
code: SERVICE_UNREACHABLE_ERROR_CODE,
|
|
70
|
+
message
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function buildAuthErrorResponse(id) {
|
|
75
|
+
const message = "Action blocked: Shield API key is invalid or has been revoked. Run npx multicorn-shield init to reconfigure.";
|
|
76
|
+
return {
|
|
77
|
+
jsonrpc: "2.0",
|
|
78
|
+
id,
|
|
79
|
+
error: {
|
|
80
|
+
code: AUTH_ERROR_CODE,
|
|
81
|
+
message
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function isJsonRpcRequest(value) {
|
|
86
|
+
if (typeof value !== "object" || value === null) return false;
|
|
87
|
+
const obj = value;
|
|
88
|
+
if (obj["jsonrpc"] !== "2.0") return false;
|
|
89
|
+
if (typeof obj["method"] !== "string") return false;
|
|
90
|
+
const id = obj["id"];
|
|
91
|
+
const validId = id === null || id === void 0 || typeof id === "string" || typeof id === "number";
|
|
92
|
+
return validId;
|
|
93
|
+
}
|
|
94
|
+
function capitalize(str) {
|
|
95
|
+
if (str.length === 0) return str;
|
|
96
|
+
const first = str[0];
|
|
97
|
+
return first !== void 0 ? first.toUpperCase() + str.slice(1) : str;
|
|
98
|
+
}
|
|
99
|
+
function deriveDashboardUrl(baseUrl) {
|
|
100
|
+
try {
|
|
101
|
+
const url = new URL(baseUrl);
|
|
102
|
+
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
|
103
|
+
url.port = "5173";
|
|
104
|
+
url.protocol = "http:";
|
|
105
|
+
return url.toString();
|
|
106
|
+
}
|
|
107
|
+
if (url.hostname === "api.multicorn.ai") {
|
|
108
|
+
url.hostname = "app.multicorn.ai";
|
|
109
|
+
return url.toString();
|
|
110
|
+
}
|
|
111
|
+
if (url.hostname.includes("api")) {
|
|
112
|
+
url.hostname = url.hostname.replace("api", "app");
|
|
113
|
+
return url.toString();
|
|
114
|
+
}
|
|
115
|
+
if (url.protocol === "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
116
|
+
return "https://app.multicorn.ai";
|
|
117
|
+
}
|
|
118
|
+
return "https://app.multicorn.ai";
|
|
119
|
+
} catch {
|
|
120
|
+
return "https://app.multicorn.ai";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
var ShieldAuthError = class _ShieldAuthError extends Error {
|
|
124
|
+
constructor(message) {
|
|
125
|
+
super(message);
|
|
126
|
+
this.name = "ShieldAuthError";
|
|
127
|
+
Object.setPrototypeOf(this, _ShieldAuthError.prototype);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
async function findAgentByName(agentName, apiKey, baseUrl) {
|
|
131
|
+
let response;
|
|
132
|
+
try {
|
|
133
|
+
response = await fetch(`${baseUrl}/api/v1/agents`, {
|
|
134
|
+
headers: { "X-Multicorn-Key": apiKey },
|
|
135
|
+
signal: AbortSignal.timeout(8e3)
|
|
136
|
+
});
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
if (response.status === 401 || response.status === 403) {
|
|
142
|
+
return { id: "", name: agentName, scopes: [], authInvalid: true };
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
let body;
|
|
147
|
+
try {
|
|
148
|
+
body = await response.json();
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
if (!isApiSuccessResponse(body)) return null;
|
|
153
|
+
const agents = body.data;
|
|
154
|
+
if (!Array.isArray(agents)) return null;
|
|
155
|
+
const match = agents.find(
|
|
156
|
+
(a) => isAgentSummaryShape(a) && a.name === agentName
|
|
157
|
+
);
|
|
158
|
+
if (match === void 0) return null;
|
|
159
|
+
return { id: match.id, name: match.name, scopes: [] };
|
|
160
|
+
}
|
|
161
|
+
async function registerAgent(agentName, apiKey, baseUrl, platform) {
|
|
162
|
+
const response = await fetch(`${baseUrl}/api/v1/agents`, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: {
|
|
165
|
+
"Content-Type": "application/json",
|
|
166
|
+
"X-Multicorn-Key": apiKey
|
|
167
|
+
},
|
|
168
|
+
body: JSON.stringify({ name: agentName, ...platform ? { platform } : {} }),
|
|
169
|
+
signal: AbortSignal.timeout(8e3)
|
|
170
|
+
});
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
if (response.status === 401 || response.status === 403) {
|
|
173
|
+
throw new ShieldAuthError(
|
|
174
|
+
`Failed to register agent "${agentName}": service returned ${String(response.status)}.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Failed to register agent "${agentName}": service returned ${String(response.status)}.`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const body = await response.json();
|
|
182
|
+
if (!isApiSuccessResponse(body)) {
|
|
183
|
+
throw new Error(`Failed to register agent "${agentName}": unexpected response format.`);
|
|
184
|
+
}
|
|
185
|
+
if (!isAgentSummaryShape(body.data)) {
|
|
186
|
+
throw new Error(`Failed to register agent "${agentName}": response missing agent ID.`);
|
|
187
|
+
}
|
|
188
|
+
return body.data.id;
|
|
189
|
+
}
|
|
190
|
+
async function fetchGrantedScopes(agentId, apiKey, baseUrl) {
|
|
191
|
+
let response;
|
|
192
|
+
try {
|
|
193
|
+
response = await fetch(`${baseUrl}/api/v1/agents/${agentId}`, {
|
|
194
|
+
headers: { "X-Multicorn-Key": apiKey },
|
|
195
|
+
signal: AbortSignal.timeout(8e3)
|
|
196
|
+
});
|
|
197
|
+
} catch {
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
if (!response.ok) return [];
|
|
201
|
+
const body = await response.json();
|
|
202
|
+
if (!isApiSuccessResponse(body)) return [];
|
|
203
|
+
const agentDetail = body.data;
|
|
204
|
+
if (!isAgentDetailShape(agentDetail)) return [];
|
|
205
|
+
const scopes = [];
|
|
206
|
+
for (const perm of agentDetail.permissions) {
|
|
207
|
+
if (!isPermissionShape(perm)) continue;
|
|
208
|
+
if (perm.revoked_at !== null) continue;
|
|
209
|
+
if (perm.read) scopes.push({ service: perm.service, permissionLevel: "read" });
|
|
210
|
+
if (perm.write) scopes.push({ service: perm.service, permissionLevel: "write" });
|
|
211
|
+
if (perm.delete === true) scopes.push({ service: perm.service, permissionLevel: "delete" });
|
|
212
|
+
if (perm.execute) scopes.push({ service: perm.service, permissionLevel: "execute" });
|
|
213
|
+
}
|
|
214
|
+
return scopes;
|
|
215
|
+
}
|
|
216
|
+
function isApiSuccessResponse(value) {
|
|
217
|
+
if (typeof value !== "object" || value === null) return false;
|
|
218
|
+
const obj = value;
|
|
219
|
+
return obj["success"] === true;
|
|
220
|
+
}
|
|
221
|
+
function isAgentSummaryShape(value) {
|
|
222
|
+
if (typeof value !== "object" || value === null) return false;
|
|
223
|
+
const obj = value;
|
|
224
|
+
return typeof obj["id"] === "string" && typeof obj["name"] === "string";
|
|
225
|
+
}
|
|
226
|
+
function isAgentDetailShape(value) {
|
|
227
|
+
if (typeof value !== "object" || value === null) return false;
|
|
228
|
+
const obj = value;
|
|
229
|
+
return Array.isArray(obj["permissions"]);
|
|
230
|
+
}
|
|
231
|
+
function isPermissionShape(value) {
|
|
232
|
+
if (typeof value !== "object" || value === null) return false;
|
|
233
|
+
const obj = value;
|
|
234
|
+
return typeof obj["service"] === "string" && typeof obj["read"] === "boolean" && typeof obj["write"] === "boolean" && typeof obj["execute"] === "boolean" && (obj["revoked_at"] === null || obj["revoked_at"] === void 0 || typeof obj["revoked_at"] === "string");
|
|
235
|
+
}
|
|
236
|
+
var LOG_LEVELS = {
|
|
237
|
+
debug: 0,
|
|
238
|
+
info: 1,
|
|
239
|
+
warn: 2,
|
|
240
|
+
error: 3
|
|
241
|
+
};
|
|
242
|
+
function createLogger(level, output = process.stderr) {
|
|
243
|
+
const minLevel = LOG_LEVELS[level];
|
|
244
|
+
function write(logLevel, msg, data) {
|
|
245
|
+
if (LOG_LEVELS[logLevel] < minLevel) return;
|
|
246
|
+
const entry = {
|
|
247
|
+
level: logLevel,
|
|
248
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
249
|
+
msg,
|
|
250
|
+
...data
|
|
251
|
+
};
|
|
252
|
+
output.write(JSON.stringify(entry) + "\n");
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
debug: (msg, data) => {
|
|
256
|
+
write("debug", msg, data);
|
|
257
|
+
},
|
|
258
|
+
info: (msg, data) => {
|
|
259
|
+
write("info", msg, data);
|
|
260
|
+
},
|
|
261
|
+
warn: (msg, data) => {
|
|
262
|
+
write("warn", msg, data);
|
|
263
|
+
},
|
|
264
|
+
error: (msg, data) => {
|
|
265
|
+
write("error", msg, data);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function isValidLogLevel(value) {
|
|
270
|
+
return typeof value === "string" && Object.hasOwn(LOG_LEVELS, value);
|
|
271
|
+
}
|
|
272
|
+
var PERMISSION_LEVELS = {
|
|
273
|
+
Read: "read",
|
|
274
|
+
Write: "write",
|
|
275
|
+
Delete: "delete",
|
|
276
|
+
Execute: "execute",
|
|
277
|
+
Publish: "publish",
|
|
278
|
+
Create: "create"
|
|
279
|
+
};
|
|
280
|
+
var VALID_PERMISSION_LEVELS = new Set(Object.values(PERMISSION_LEVELS));
|
|
281
|
+
[...VALID_PERMISSION_LEVELS].join(", ");
|
|
282
|
+
function formatScope(scope) {
|
|
283
|
+
return `${scope.permissionLevel}:${scope.service}`;
|
|
284
|
+
}
|
|
285
|
+
function validateScopeAccess(grantedScopes, requested) {
|
|
286
|
+
const isGranted = grantedScopes.some(
|
|
287
|
+
(granted) => granted.service === requested.service && granted.permissionLevel === requested.permissionLevel
|
|
288
|
+
);
|
|
289
|
+
if (isGranted) {
|
|
290
|
+
return { allowed: true };
|
|
291
|
+
}
|
|
292
|
+
const serviceScopes = grantedScopes.filter((g) => g.service === requested.service);
|
|
293
|
+
if (serviceScopes.length > 0) {
|
|
294
|
+
const grantedLevels = serviceScopes.map((g) => `"${g.permissionLevel}"`).join(", ");
|
|
295
|
+
return {
|
|
296
|
+
allowed: false,
|
|
297
|
+
reason: `Permission "${requested.permissionLevel}" is not granted for service "${requested.service}". Currently granted permission level(s): ${grantedLevels}. Requested scope "${formatScope(requested)}" requires explicit consent.`
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
allowed: false,
|
|
302
|
+
reason: `No permissions granted for service "${requested.service}". The agent has not been authorised to access this service. Request scope "${formatScope(requested)}" via the consent screen.`
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
var FILESYSTEM_READ_TOOLS = /* @__PURE__ */ new Set([
|
|
306
|
+
"read_file",
|
|
307
|
+
"read_text_file",
|
|
308
|
+
"read_media_file",
|
|
309
|
+
"read_multiple_files",
|
|
310
|
+
"list_directory",
|
|
311
|
+
"list_dir",
|
|
312
|
+
"directory_tree",
|
|
313
|
+
"tree",
|
|
314
|
+
"get_file_info",
|
|
315
|
+
"stat",
|
|
316
|
+
"search_files",
|
|
317
|
+
"glob_file_search",
|
|
318
|
+
"list_allowed_directories",
|
|
319
|
+
"file_search"
|
|
320
|
+
]);
|
|
321
|
+
var FILESYSTEM_WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
322
|
+
"write_file",
|
|
323
|
+
"edit_file",
|
|
324
|
+
"create_directory",
|
|
325
|
+
"mkdir",
|
|
326
|
+
"move_file",
|
|
327
|
+
"rename",
|
|
328
|
+
"delete_file",
|
|
329
|
+
"remove_file",
|
|
330
|
+
"copy_file"
|
|
331
|
+
]);
|
|
332
|
+
var TERMINAL_EXECUTE_TOOLS = /* @__PURE__ */ new Set([
|
|
333
|
+
"run_terminal_cmd",
|
|
334
|
+
"execute_command",
|
|
335
|
+
"terminal_run",
|
|
336
|
+
"run_command"
|
|
337
|
+
]);
|
|
338
|
+
var BROWSER_EXECUTE_TOOLS = /* @__PURE__ */ new Set([
|
|
339
|
+
"web_fetch",
|
|
340
|
+
"fetch_url",
|
|
341
|
+
"browser_navigate",
|
|
342
|
+
"navigate",
|
|
343
|
+
"mcp_web_fetch"
|
|
344
|
+
]);
|
|
345
|
+
var INTEGRATION_SERVICE_BY_PREFIX = {
|
|
346
|
+
gmail: "gmail",
|
|
347
|
+
google_calendar: "google_calendar",
|
|
348
|
+
calendar: "google_calendar",
|
|
349
|
+
google_drive: "google_drive",
|
|
350
|
+
drive: "google_drive",
|
|
351
|
+
slack: "slack",
|
|
352
|
+
payments: "payments",
|
|
353
|
+
payment: "payments",
|
|
354
|
+
stripe: "payments",
|
|
355
|
+
github: "github",
|
|
356
|
+
gitlab: "gitlab",
|
|
357
|
+
notion: "notion",
|
|
358
|
+
linear: "linear",
|
|
359
|
+
jira: "jira"
|
|
360
|
+
};
|
|
361
|
+
function inferPermissionFromToolName(normalized) {
|
|
362
|
+
if (normalized.includes("_read") || normalized.includes("_get") || normalized.includes("_list") || normalized.endsWith("_fetch") || normalized.includes("_search")) {
|
|
363
|
+
return "read";
|
|
364
|
+
}
|
|
365
|
+
if (normalized.includes("_write") || normalized.includes("_send") || normalized.includes("_create") || normalized.includes("_update") || normalized.includes("_delete") || normalized.includes("_push") || normalized.includes("_commit") || normalized.includes("_post") || normalized.includes("_patch")) {
|
|
366
|
+
return "write";
|
|
367
|
+
}
|
|
368
|
+
return "execute";
|
|
369
|
+
}
|
|
370
|
+
function mapMcpToolToScope(toolName) {
|
|
371
|
+
const actionType = toolName.trim();
|
|
372
|
+
const normalized = actionType.toLowerCase();
|
|
373
|
+
if (normalized.length === 0) {
|
|
374
|
+
return { service: "unknown", permissionLevel: "execute", actionType };
|
|
375
|
+
}
|
|
376
|
+
if (FILESYSTEM_READ_TOOLS.has(normalized)) {
|
|
377
|
+
return { service: "filesystem", permissionLevel: "read", actionType };
|
|
378
|
+
}
|
|
379
|
+
if (FILESYSTEM_WRITE_TOOLS.has(normalized)) {
|
|
380
|
+
return { service: "filesystem", permissionLevel: "write", actionType };
|
|
381
|
+
}
|
|
382
|
+
if (TERMINAL_EXECUTE_TOOLS.has(normalized)) {
|
|
383
|
+
return { service: "terminal", permissionLevel: "execute", actionType };
|
|
384
|
+
}
|
|
385
|
+
if (BROWSER_EXECUTE_TOOLS.has(normalized)) {
|
|
386
|
+
return { service: "browser", permissionLevel: "execute", actionType };
|
|
387
|
+
}
|
|
388
|
+
if (normalized === "read") {
|
|
389
|
+
return { service: "filesystem", permissionLevel: "read", actionType };
|
|
390
|
+
}
|
|
391
|
+
if (normalized === "write" || normalized === "edit") {
|
|
392
|
+
return { service: "filesystem", permissionLevel: "write", actionType };
|
|
393
|
+
}
|
|
394
|
+
if (normalized === "exec") {
|
|
395
|
+
return { service: "terminal", permissionLevel: "execute", actionType };
|
|
396
|
+
}
|
|
397
|
+
if (normalized.startsWith("git_")) {
|
|
398
|
+
const permissionLevel2 = inferPermissionFromToolName(normalized);
|
|
399
|
+
return { service: "git", permissionLevel: permissionLevel2, actionType };
|
|
400
|
+
}
|
|
401
|
+
for (const [prefix, service] of Object.entries(INTEGRATION_SERVICE_BY_PREFIX)) {
|
|
402
|
+
if (normalized.startsWith(`${prefix}_`) || normalized === prefix) {
|
|
403
|
+
const permissionLevel2 = inferPermissionFromToolName(normalized);
|
|
404
|
+
return { service, permissionLevel: permissionLevel2, actionType };
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const idx = normalized.indexOf("_");
|
|
408
|
+
if (idx === -1) {
|
|
409
|
+
return { service: normalized, permissionLevel: "execute", actionType };
|
|
410
|
+
}
|
|
411
|
+
const head = normalized.slice(0, idx);
|
|
412
|
+
const tail = normalized.slice(idx + 1);
|
|
413
|
+
let permissionLevel = "execute";
|
|
414
|
+
if (tail.includes("read") || tail.includes("list") || tail.includes("get") || tail.includes("search") || tail.includes("fetch")) {
|
|
415
|
+
permissionLevel = "read";
|
|
416
|
+
} else if (tail.includes("write") || tail.includes("send") || tail.includes("create") || tail.includes("update") || tail.includes("delete") || tail.includes("remove")) {
|
|
417
|
+
permissionLevel = "write";
|
|
418
|
+
}
|
|
419
|
+
return { service: head, permissionLevel, actionType };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ../multicorn-proxy/src/auth.ts
|
|
423
|
+
function extractApiKey(headers, searchParams) {
|
|
424
|
+
const multicornKey = headers["x-multicorn-key"];
|
|
425
|
+
if (typeof multicornKey === "string" && multicornKey.length > 0) {
|
|
426
|
+
return multicornKey;
|
|
427
|
+
}
|
|
428
|
+
const authHeader = headers["authorization"];
|
|
429
|
+
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
|
|
430
|
+
const token = authHeader.slice(7).trim();
|
|
431
|
+
if (token.length > 0) {
|
|
432
|
+
return token;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (searchParams !== void 0) {
|
|
436
|
+
const fromQuery = searchParams.get("key");
|
|
437
|
+
if (typeof fromQuery === "string" && fromQuery.length > 0) {
|
|
438
|
+
return fromQuery;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return void 0;
|
|
442
|
+
}
|
|
443
|
+
function readProxyVersion() {
|
|
444
|
+
if ("0.1.0".length > 0) {
|
|
445
|
+
return "0.1.0";
|
|
446
|
+
}
|
|
447
|
+
try {
|
|
448
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
449
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
450
|
+
const v = JSON.parse(raw);
|
|
451
|
+
return typeof v.version === "string" ? v.version : "0.0.0";
|
|
452
|
+
} catch {
|
|
453
|
+
return "0.0.0";
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
var PROXY_VERSION = readProxyVersion();
|
|
457
|
+
var PROXY_VERSION_HEADER = "X-Multicorn-Proxy-Version";
|
|
458
|
+
|
|
459
|
+
// ../multicorn-proxy/src/config-resolver.ts
|
|
460
|
+
var MULTICORN_MCP_SENTINEL = "multicorn://mcp";
|
|
461
|
+
var PROXY_RESOLVE_SECRET_HEADER = "X-Multicorn-Proxy-Resolve-Secret";
|
|
462
|
+
var PROXY_LOCAL_HEADER = "X-Multicorn-Proxy-Local";
|
|
463
|
+
function hashKey(apiKey) {
|
|
464
|
+
return createHash("sha256").update(apiKey, "utf8").digest("hex");
|
|
465
|
+
}
|
|
466
|
+
var ALLOWED_SCHEMES = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
467
|
+
var ALLOWED_EXPLICIT_PORTS = /* @__PURE__ */ new Set(["80", "443", "8080", "8443"]);
|
|
468
|
+
var TargetUrlError = class extends Error {
|
|
469
|
+
constructor(message) {
|
|
470
|
+
super(message);
|
|
471
|
+
this.name = "TargetUrlError";
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
function schemeLabel(protocol) {
|
|
475
|
+
return protocol.endsWith(":") ? protocol.slice(0, -1) : protocol;
|
|
476
|
+
}
|
|
477
|
+
function ipv4ToParts(host) {
|
|
478
|
+
const parts = host.split(".");
|
|
479
|
+
if (parts.length !== 4) return null;
|
|
480
|
+
const nums = [];
|
|
481
|
+
for (const p of parts) {
|
|
482
|
+
const v = Number(p);
|
|
483
|
+
if (!Number.isInteger(v) || v < 0 || v > 255) return null;
|
|
484
|
+
nums.push(v);
|
|
485
|
+
}
|
|
486
|
+
return [nums[0], nums[1], nums[2], nums[3]];
|
|
487
|
+
}
|
|
488
|
+
function isBlockedIPv4Literal(host) {
|
|
489
|
+
if (host === "0.0.0.0") return true;
|
|
490
|
+
const quad = ipv4ToParts(host);
|
|
491
|
+
if (quad === null) return false;
|
|
492
|
+
const [a, b] = quad;
|
|
493
|
+
if (a === 10) return true;
|
|
494
|
+
if (a === 127) return true;
|
|
495
|
+
if (a === 169 && b === 254) return true;
|
|
496
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
497
|
+
if (a === 192 && b === 168) return true;
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
function isBlockedIPv6Literal(host) {
|
|
501
|
+
const h = host.toLowerCase();
|
|
502
|
+
if (h === "::1") return true;
|
|
503
|
+
if (h.startsWith("fe80:")) return true;
|
|
504
|
+
if (h.startsWith("fc") || h.startsWith("fd")) return true;
|
|
505
|
+
const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(h);
|
|
506
|
+
if (mapped !== null) {
|
|
507
|
+
return isBlockedIPv4Literal(mapped[1]);
|
|
508
|
+
}
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
function normaliseHostnameForValidation(hostname) {
|
|
512
|
+
if (hostname.length >= 2 && hostname.startsWith("[") && hostname.endsWith("]")) {
|
|
513
|
+
return hostname.slice(1, -1);
|
|
514
|
+
}
|
|
515
|
+
return hostname;
|
|
516
|
+
}
|
|
517
|
+
function validateTargetUrl(targetUrl, allowPrivateTargets) {
|
|
518
|
+
if (targetUrl === "multicorn://mcp") return;
|
|
519
|
+
let parsed;
|
|
520
|
+
try {
|
|
521
|
+
parsed = new URL(targetUrl);
|
|
522
|
+
} catch {
|
|
523
|
+
throw new TargetUrlError("targetUrl is not a valid URL");
|
|
524
|
+
}
|
|
525
|
+
if (!ALLOWED_SCHEMES.has(parsed.protocol)) {
|
|
526
|
+
throw new TargetUrlError(`targetUrl uses blocked scheme: ${schemeLabel(parsed.protocol)}`);
|
|
527
|
+
}
|
|
528
|
+
if (allowPrivateTargets) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const port = parsed.port;
|
|
532
|
+
if (port !== "" && !ALLOWED_EXPLICIT_PORTS.has(port)) {
|
|
533
|
+
throw new TargetUrlError(`targetUrl uses disallowed port: ${port}`);
|
|
534
|
+
}
|
|
535
|
+
const host = normaliseHostnameForValidation(parsed.hostname);
|
|
536
|
+
if (host === "") {
|
|
537
|
+
throw new TargetUrlError("targetUrl has empty host");
|
|
538
|
+
}
|
|
539
|
+
if (host === "0.0.0.0") {
|
|
540
|
+
throw new TargetUrlError("targetUrl host is blocked: 0.0.0.0");
|
|
541
|
+
}
|
|
542
|
+
if (host.toLowerCase() === "localhost") {
|
|
543
|
+
throw new TargetUrlError("targetUrl host is blocked: localhost");
|
|
544
|
+
}
|
|
545
|
+
const v = isIP(host);
|
|
546
|
+
if (v === 4) {
|
|
547
|
+
if (isBlockedIPv4Literal(host)) {
|
|
548
|
+
throw new TargetUrlError(`targetUrl resolves to private IP range: ${host}`);
|
|
549
|
+
}
|
|
550
|
+
} else if (v === 6) {
|
|
551
|
+
if (isBlockedIPv6Literal(host)) {
|
|
552
|
+
throw new TargetUrlError(`targetUrl uses blocked IPv6 host: ${host}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
function createConfigResolver(shieldApiBaseUrl, ttlMs, allowPrivateTargets, proxyResolveInternalSecret, mcpTtlMs = ttlMs) {
|
|
557
|
+
const cache = /* @__PURE__ */ new Map();
|
|
558
|
+
const lastSeenAgentName = /* @__PURE__ */ new Map();
|
|
559
|
+
function prune() {
|
|
560
|
+
const now = Date.now();
|
|
561
|
+
for (const [k, b] of cache) {
|
|
562
|
+
if (b.expiresAt <= now) cache.delete(k);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
hashKey,
|
|
567
|
+
getLastSeenAgentName(routingToken) {
|
|
568
|
+
return lastSeenAgentName.get(routingToken);
|
|
569
|
+
},
|
|
570
|
+
trackAgentName(routingToken, agentName) {
|
|
571
|
+
const prev = lastSeenAgentName.get(routingToken);
|
|
572
|
+
lastSeenAgentName.set(routingToken, agentName);
|
|
573
|
+
return prev !== void 0 && prev !== agentName;
|
|
574
|
+
},
|
|
575
|
+
async resolve(routingToken, apiKey) {
|
|
576
|
+
prune();
|
|
577
|
+
const ck = `${routingToken}|${hashKey(apiKey)}`;
|
|
578
|
+
const hit = cache.get(ck);
|
|
579
|
+
if (hit !== void 0 && hit.expiresAt > Date.now()) {
|
|
580
|
+
return hit.body;
|
|
581
|
+
}
|
|
582
|
+
const url = `${shieldApiBaseUrl}/api/v1/proxy/config/resolve/${encodeURIComponent(routingToken)}`;
|
|
583
|
+
const headers = {
|
|
584
|
+
"X-Multicorn-Key": apiKey,
|
|
585
|
+
// Report the running proxy version so the backend can stamp the agent's
|
|
586
|
+
// last-seen version + timestamp. Resolve runs on the first request and on
|
|
587
|
+
// every cache miss (~60s while the agent is active), so it doubles as a
|
|
588
|
+
// lightweight liveness heartbeat without a dedicated endpoint.
|
|
589
|
+
[PROXY_VERSION_HEADER]: PROXY_VERSION
|
|
590
|
+
};
|
|
591
|
+
if (proxyResolveInternalSecret !== void 0 && proxyResolveInternalSecret.length > 0) {
|
|
592
|
+
headers[PROXY_RESOLVE_SECRET_HEADER] = proxyResolveInternalSecret;
|
|
593
|
+
}
|
|
594
|
+
if (allowPrivateTargets) {
|
|
595
|
+
headers[PROXY_LOCAL_HEADER] = "1";
|
|
596
|
+
}
|
|
597
|
+
const response = await fetch(url, {
|
|
598
|
+
method: "GET",
|
|
599
|
+
headers,
|
|
600
|
+
signal: AbortSignal.timeout(15e3),
|
|
601
|
+
redirect: "manual"
|
|
602
|
+
});
|
|
603
|
+
if (response.status === 401) {
|
|
604
|
+
throw new ResolveError("unauthorized", "Invalid or missing API key");
|
|
605
|
+
}
|
|
606
|
+
if (response.status === 403) {
|
|
607
|
+
throw new ResolveError("forbidden", "API key cannot access this route");
|
|
608
|
+
}
|
|
609
|
+
if (response.status === 404) {
|
|
610
|
+
throw new ResolveError("not_found", "Unknown routing token");
|
|
611
|
+
}
|
|
612
|
+
if (!response.ok) {
|
|
613
|
+
throw new ResolveError("upstream", `Resolve failed: HTTP ${String(response.status)}`);
|
|
614
|
+
}
|
|
615
|
+
const json = await response.json();
|
|
616
|
+
if (!isSuccessEnvelope(json)) {
|
|
617
|
+
throw new ResolveError("upstream", "Unexpected resolve response shape");
|
|
618
|
+
}
|
|
619
|
+
const body = parseProxyConfigResolveBody(json.data, allowPrivateTargets);
|
|
620
|
+
if (body === null) {
|
|
621
|
+
throw new ResolveError("upstream", "Unexpected resolve response shape");
|
|
622
|
+
}
|
|
623
|
+
const effectiveTtl = body.targetUrl === MULTICORN_MCP_SENTINEL ? mcpTtlMs : ttlMs;
|
|
624
|
+
cache.set(ck, { body, expiresAt: Date.now() + effectiveTtl });
|
|
625
|
+
return body;
|
|
626
|
+
},
|
|
627
|
+
invalidate(routingToken, apiKey) {
|
|
628
|
+
cache.delete(`${routingToken}|${hashKey(apiKey)}`);
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
var ResolveError = class extends Error {
|
|
633
|
+
constructor(code, message) {
|
|
634
|
+
super(message);
|
|
635
|
+
this.code = code;
|
|
636
|
+
this.name = "ResolveError";
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
function isSuccessEnvelope(v) {
|
|
640
|
+
return typeof v === "object" && v !== null && v.success === true && "data" in v;
|
|
641
|
+
}
|
|
642
|
+
function parseProxyConfigResolveBody(v, allowPrivateTargets) {
|
|
643
|
+
if (typeof v !== "object" || v === null) return null;
|
|
644
|
+
const o = v;
|
|
645
|
+
const targetUrl = typeof o["target_url"] === "string" ? o["target_url"] : typeof o["targetUrl"] === "string" ? o["targetUrl"] : void 0;
|
|
646
|
+
const serverName = typeof o["server_name"] === "string" ? o["server_name"] : typeof o["serverName"] === "string" ? o["serverName"] : void 0;
|
|
647
|
+
const userId = typeof o["user_id"] === "string" ? o["user_id"] : typeof o["userId"] === "string" ? o["userId"] : void 0;
|
|
648
|
+
if (targetUrl === void 0 || serverName === void 0 || userId === void 0) {
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
const platform = typeof o["platform"] === "string" && o["platform"].length > 0 ? o["platform"] : void 0;
|
|
652
|
+
const agentName = typeof o["agent_name"] === "string" && o["agent_name"].length > 0 ? o["agent_name"] : typeof o["agentName"] === "string" && o["agentName"].length > 0 ? o["agentName"] : void 0;
|
|
653
|
+
validateTargetUrl(targetUrl, allowPrivateTargets);
|
|
654
|
+
const upstreamHeaders = parseResolveUpstreamHeaders(o);
|
|
655
|
+
const serviceTokens = parseResolveServiceTokens(o);
|
|
656
|
+
const upstreams = parseResolveUpstreams(o, allowPrivateTargets);
|
|
657
|
+
let body = {
|
|
658
|
+
targetUrl,
|
|
659
|
+
serverName,
|
|
660
|
+
userId,
|
|
661
|
+
platform,
|
|
662
|
+
agentName
|
|
663
|
+
};
|
|
664
|
+
if (upstreamHeaders !== void 0) {
|
|
665
|
+
body = { ...body, upstreamHeaders };
|
|
666
|
+
}
|
|
667
|
+
if (serviceTokens !== void 0) {
|
|
668
|
+
body = { ...body, serviceTokens };
|
|
669
|
+
}
|
|
670
|
+
if (upstreams !== void 0) {
|
|
671
|
+
body = { ...body, upstreams };
|
|
672
|
+
}
|
|
673
|
+
return body;
|
|
674
|
+
}
|
|
675
|
+
var UPSTREAM_KINDS = /* @__PURE__ */ new Set(["builtin", "hosted", "local", "http"]);
|
|
676
|
+
function parseResolveUpstreams(o, allowPrivateTargets) {
|
|
677
|
+
const raw = o["upstreams"];
|
|
678
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
679
|
+
return void 0;
|
|
680
|
+
}
|
|
681
|
+
const out = [];
|
|
682
|
+
for (const item of raw) {
|
|
683
|
+
if (typeof item !== "object" || item === null) continue;
|
|
684
|
+
const obj = item;
|
|
685
|
+
const targetUrl = typeof obj["target_url"] === "string" ? obj["target_url"] : typeof obj["targetUrl"] === "string" ? obj["targetUrl"] : void 0;
|
|
686
|
+
const kind = typeof obj["kind"] === "string" ? obj["kind"] : void 0;
|
|
687
|
+
if (targetUrl === void 0 || kind === void 0 || !UPSTREAM_KINDS.has(kind)) {
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (kind !== "builtin" && kind !== "local") {
|
|
691
|
+
validateTargetUrl(targetUrl, allowPrivateTargets);
|
|
692
|
+
}
|
|
693
|
+
const localDir = typeof obj["local_dir"] === "string" ? obj["local_dir"] : typeof obj["localDir"] === "string" ? obj["localDir"] : void 0;
|
|
694
|
+
const entry = { targetUrl, kind };
|
|
695
|
+
out.push(localDir !== void 0 ? { ...entry, localDir } : entry);
|
|
696
|
+
}
|
|
697
|
+
return out.length > 0 ? out : void 0;
|
|
698
|
+
}
|
|
699
|
+
function parseResolveServiceTokens(o) {
|
|
700
|
+
const raw = o["service_tokens"] ?? o["serviceTokens"];
|
|
701
|
+
if (raw === null || raw === void 0 || typeof raw !== "object" || Array.isArray(raw)) {
|
|
702
|
+
return void 0;
|
|
703
|
+
}
|
|
704
|
+
const obj = raw;
|
|
705
|
+
const google = parseSingleServiceToken(obj["google"]);
|
|
706
|
+
if (google === void 0) {
|
|
707
|
+
return void 0;
|
|
708
|
+
}
|
|
709
|
+
return { google };
|
|
710
|
+
}
|
|
711
|
+
function parseSingleServiceToken(raw) {
|
|
712
|
+
if (raw === null || raw === void 0 || typeof raw !== "object" || Array.isArray(raw)) {
|
|
713
|
+
return void 0;
|
|
714
|
+
}
|
|
715
|
+
const obj = raw;
|
|
716
|
+
const accessToken = typeof obj["access_token"] === "string" ? obj["access_token"] : typeof obj["accessToken"] === "string" ? obj["accessToken"] : void 0;
|
|
717
|
+
if (accessToken === void 0 || accessToken.length === 0) {
|
|
718
|
+
return void 0;
|
|
719
|
+
}
|
|
720
|
+
const grantedScopes = typeof obj["granted_scopes"] === "string" ? obj["granted_scopes"] : typeof obj["grantedScopes"] === "string" ? obj["grantedScopes"] : "";
|
|
721
|
+
return { accessToken, grantedScopes };
|
|
722
|
+
}
|
|
723
|
+
var MAX_RESOLVE_UPSTREAM_ENTRIES = 16;
|
|
724
|
+
function parseResolveUpstreamHeaders(o) {
|
|
725
|
+
const raw = o["upstream_headers"] ?? o["upstreamHeaders"];
|
|
726
|
+
if (raw === null || raw === void 0) {
|
|
727
|
+
return void 0;
|
|
728
|
+
}
|
|
729
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
730
|
+
return void 0;
|
|
731
|
+
}
|
|
732
|
+
const obj = raw;
|
|
733
|
+
const entries = Object.entries(obj);
|
|
734
|
+
if (entries.length === 0) {
|
|
735
|
+
return void 0;
|
|
736
|
+
}
|
|
737
|
+
if (entries.length > MAX_RESOLVE_UPSTREAM_ENTRIES) {
|
|
738
|
+
return void 0;
|
|
739
|
+
}
|
|
740
|
+
const out = {};
|
|
741
|
+
for (const [k, v] of entries) {
|
|
742
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
const name = k.trim();
|
|
746
|
+
if (name.length === 0 || name.length > 128) {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
out[name] = v;
|
|
750
|
+
}
|
|
751
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ../multicorn-proxy/src/env.ts
|
|
755
|
+
function loadEnv() {
|
|
756
|
+
const port = Number(process.env["PORT"] ?? 3e3);
|
|
757
|
+
const hostRaw = process.env["HOST"]?.trim();
|
|
758
|
+
const host = hostRaw !== void 0 && hostRaw.length > 0 ? hostRaw : void 0;
|
|
759
|
+
const base = process.env["SHIELD_API_BASE_URL"]?.trim().replace(/\/+$/, "") ?? "https://api.multicorn.ai";
|
|
760
|
+
const rawLevel = process.env["LOG_LEVEL"] ?? "info";
|
|
761
|
+
const logLevel = isValidLogLevel(rawLevel) ? rawLevel : "info";
|
|
762
|
+
const allowPrivateTargets = process.env["ALLOW_PRIVATE_TARGETS"] === "true";
|
|
763
|
+
const resolveSecretRaw = process.env["MULTICORN_PROXY_RESOLVE_INTERNAL_SECRET"]?.trim();
|
|
764
|
+
return {
|
|
765
|
+
port: Number.isFinite(port) && port > 0 ? port : 3e3,
|
|
766
|
+
host,
|
|
767
|
+
shieldApiBaseUrl: base,
|
|
768
|
+
logLevel,
|
|
769
|
+
rateLimitRpm: Math.max(1, Number(process.env["RATE_LIMIT_RPM"] ?? 100)),
|
|
770
|
+
configResolveTtlMs: Math.max(
|
|
771
|
+
1e3,
|
|
772
|
+
Number(process.env["CONFIG_RESOLVE_TTL_MS"] ?? 6e4)
|
|
773
|
+
),
|
|
774
|
+
configResolveMcpTtlMs: Math.max(
|
|
775
|
+
1e3,
|
|
776
|
+
Number(process.env["CONFIG_RESOLVE_MCP_TTL_MS"] ?? 3e4)
|
|
777
|
+
),
|
|
778
|
+
scopeCacheTtlMs: Math.max(1e3, Number(process.env["SCOPE_CACHE_TTL_MS"] ?? 6e4)),
|
|
779
|
+
allowPrivateTargets,
|
|
780
|
+
serverRequestTimeoutMs: Math.max(
|
|
781
|
+
1e3,
|
|
782
|
+
Number(process.env["SERVER_REQUEST_TIMEOUT_MS"] ?? 3e4)
|
|
783
|
+
),
|
|
784
|
+
serverHeadersTimeoutMs: Math.max(
|
|
785
|
+
1e3,
|
|
786
|
+
Number(process.env["SERVER_HEADERS_TIMEOUT_MS"] ?? 1e4)
|
|
787
|
+
),
|
|
788
|
+
proxyResolveInternalSecret: resolveSecretRaw !== void 0 && resolveSecretRaw.length > 0 ? resolveSecretRaw : void 0
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ../multicorn-proxy/src/query-key.ts
|
|
793
|
+
function stripKeyQueryParamFromUrl(urlString) {
|
|
794
|
+
try {
|
|
795
|
+
const u = new URL(urlString);
|
|
796
|
+
if (!u.searchParams.has("key")) return urlString;
|
|
797
|
+
u.searchParams.delete("key");
|
|
798
|
+
return u.toString();
|
|
799
|
+
} catch {
|
|
800
|
+
return urlString;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
function redactKeyQueryParamForLogs(urlString) {
|
|
804
|
+
if (urlString.length === 0) return urlString;
|
|
805
|
+
const lowered = urlString.toLowerCase();
|
|
806
|
+
if (!lowered.includes("key=") && !lowered.includes("key%3d")) {
|
|
807
|
+
return urlString;
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
const isAbsolute = urlString.startsWith("http://") || urlString.startsWith("https://");
|
|
811
|
+
const u = isAbsolute ? new URL(urlString) : new URL(urlString, "http://127.0.0.1");
|
|
812
|
+
if (u.searchParams.has("key")) {
|
|
813
|
+
u.searchParams.set("key", "[redacted]");
|
|
814
|
+
}
|
|
815
|
+
if (isAbsolute) {
|
|
816
|
+
return u.toString();
|
|
817
|
+
}
|
|
818
|
+
return u.pathname + u.search + u.hash;
|
|
819
|
+
} catch {
|
|
820
|
+
return urlString.replace(/([?&])key=[^&]*/gi, "$1key=[redacted]");
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ../multicorn-proxy/src/mcp-handshake.ts
|
|
825
|
+
var KNOWN_PROTOCOL_VERSIONS = /* @__PURE__ */ new Set([
|
|
826
|
+
"2024-11-05",
|
|
827
|
+
"2025-03-26",
|
|
828
|
+
"2025-06-18"
|
|
829
|
+
]);
|
|
830
|
+
var FALLBACK_PROTOCOL_VERSION = "2024-11-05";
|
|
831
|
+
var proxySemver = PROXY_VERSION;
|
|
832
|
+
function negotiateMcpProtocolVersion(clientParams) {
|
|
833
|
+
if (typeof clientParams !== "object" || clientParams === null) {
|
|
834
|
+
return FALLBACK_PROTOCOL_VERSION;
|
|
835
|
+
}
|
|
836
|
+
const v = clientParams["protocolVersion"];
|
|
837
|
+
if (typeof v === "string" && v.length > 0 && KNOWN_PROTOCOL_VERSIONS.has(v)) {
|
|
838
|
+
return v;
|
|
839
|
+
}
|
|
840
|
+
return FALLBACK_PROTOCOL_VERSION;
|
|
841
|
+
}
|
|
842
|
+
function classifyUnauthenticatedMcpHandshake(rpc) {
|
|
843
|
+
if (rpc === null) return null;
|
|
844
|
+
if (rpc.method === "initialize") return "initialize";
|
|
845
|
+
if (rpc.method === "notifications/initialized") return "initialized_notification";
|
|
846
|
+
if (rpc.method === "tools/list") return "tools_list";
|
|
847
|
+
if (rpc.method === "prompts/list") return "prompts_list";
|
|
848
|
+
if (rpc.method === "resources/list") return "resources_list";
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
function buildUnauthenticatedDiscoveryListResponse(rpc, kind) {
|
|
852
|
+
const result = kind === "tools_list" ? { tools: [] } : kind === "prompts_list" ? { prompts: [] } : { resources: [] };
|
|
853
|
+
const payload = {
|
|
854
|
+
jsonrpc: "2.0",
|
|
855
|
+
id: rpc.id,
|
|
856
|
+
result
|
|
857
|
+
};
|
|
858
|
+
return JSON.stringify(payload);
|
|
859
|
+
}
|
|
860
|
+
function buildUnauthenticatedInitializeResponse(rpc) {
|
|
861
|
+
const protocolVersion = negotiateMcpProtocolVersion(rpc.params);
|
|
862
|
+
const payload = {
|
|
863
|
+
jsonrpc: "2.0",
|
|
864
|
+
id: rpc.id,
|
|
865
|
+
result: {
|
|
866
|
+
protocolVersion,
|
|
867
|
+
capabilities: {
|
|
868
|
+
tools: {},
|
|
869
|
+
resources: {},
|
|
870
|
+
prompts: {}
|
|
871
|
+
},
|
|
872
|
+
serverInfo: {
|
|
873
|
+
name: "multicorn-proxy",
|
|
874
|
+
version: proxySemver
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
return JSON.stringify(payload);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ../multicorn-proxy/src/mcp-forwarder.ts
|
|
882
|
+
var PathTraversalError = class extends Error {
|
|
883
|
+
constructor(path) {
|
|
884
|
+
super(`Path traversal detected: ${path}`);
|
|
885
|
+
this.name = "PathTraversalError";
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
function fullyDecode(value) {
|
|
889
|
+
let result = value;
|
|
890
|
+
for (let i = 0; i < 3; i++) {
|
|
891
|
+
try {
|
|
892
|
+
const next = decodeURIComponent(result);
|
|
893
|
+
if (next === result) return result;
|
|
894
|
+
result = next;
|
|
895
|
+
} catch {
|
|
896
|
+
throw new PathTraversalError(value);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
return result;
|
|
900
|
+
}
|
|
901
|
+
function assertSafeRestPath(restPath) {
|
|
902
|
+
const decoded = fullyDecode(restPath);
|
|
903
|
+
if (decoded.includes("\0")) {
|
|
904
|
+
throw new PathTraversalError(restPath);
|
|
905
|
+
}
|
|
906
|
+
const segments = decoded.split("/");
|
|
907
|
+
for (const seg of segments) {
|
|
908
|
+
if (seg === ".." || seg === ".") {
|
|
909
|
+
throw new PathTraversalError(restPath);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
function resolvedPathWithinBase(resolvedPathname, originalPathname) {
|
|
914
|
+
if (originalPathname === "/" || originalPathname === "") {
|
|
915
|
+
return resolvedPathname.startsWith("/");
|
|
916
|
+
}
|
|
917
|
+
const basePrefix = originalPathname.endsWith("/") ? originalPathname.slice(0, -1) : originalPathname;
|
|
918
|
+
return resolvedPathname === basePrefix || resolvedPathname.startsWith(`${basePrefix}/`);
|
|
919
|
+
}
|
|
920
|
+
function buildForwardUrl(baseTarget, restPath) {
|
|
921
|
+
const base = new URL(baseTarget);
|
|
922
|
+
const originalPathname = base.pathname;
|
|
923
|
+
if (restPath === "" || restPath === "/") {
|
|
924
|
+
return base.toString();
|
|
925
|
+
}
|
|
926
|
+
assertSafeRestPath(restPath);
|
|
927
|
+
const extra = restPath.startsWith("/") ? restPath : `/${restPath}`;
|
|
928
|
+
const prefix = base.pathname.endsWith("/") ? base.pathname.slice(0, -1) : base.pathname;
|
|
929
|
+
base.pathname = prefix + extra;
|
|
930
|
+
if (!resolvedPathWithinBase(base.pathname, originalPathname)) {
|
|
931
|
+
throw new PathTraversalError(restPath);
|
|
932
|
+
}
|
|
933
|
+
return base.toString();
|
|
934
|
+
}
|
|
935
|
+
async function forwardToMcp(targetUrl, init, options) {
|
|
936
|
+
const headers = new Headers();
|
|
937
|
+
const pass = /* @__PURE__ */ new Set([
|
|
938
|
+
"content-type",
|
|
939
|
+
"accept",
|
|
940
|
+
"mcp-protocol-version",
|
|
941
|
+
"mcp-session-id"
|
|
942
|
+
]);
|
|
943
|
+
for (const name of pass) {
|
|
944
|
+
const v = init.headers[name] ?? init.headers[name.toLowerCase()];
|
|
945
|
+
if (typeof v === "string" && v.length > 0) {
|
|
946
|
+
headers.set(name, v);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const extra = options?.configUpstreamHeaders;
|
|
950
|
+
if (extra !== void 0) {
|
|
951
|
+
for (const [name, value] of Object.entries(extra)) {
|
|
952
|
+
if (typeof value === "string" && value.length > 0) {
|
|
953
|
+
headers.set(name, value);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return fetch(targetUrl, {
|
|
958
|
+
method: init.method,
|
|
959
|
+
headers,
|
|
960
|
+
body: init.body !== void 0 && init.method !== "GET" && init.method !== "HEAD" ? new Uint8Array(init.body) : void 0,
|
|
961
|
+
signal: init.signal,
|
|
962
|
+
redirect: "manual"
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
function bufferFromRequest(req, limitBytes) {
|
|
966
|
+
return new Promise((resolve, reject) => {
|
|
967
|
+
const chunks = [];
|
|
968
|
+
let total = 0;
|
|
969
|
+
req.on("data", (chunk) => {
|
|
970
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
971
|
+
total += buf.length;
|
|
972
|
+
if (total > limitBytes) {
|
|
973
|
+
reject(new Error("request body too large"));
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
chunks.push(buf);
|
|
977
|
+
});
|
|
978
|
+
req.on("end", () => {
|
|
979
|
+
resolve(Buffer.concat(chunks));
|
|
980
|
+
});
|
|
981
|
+
req.on("error", (err) => {
|
|
982
|
+
reject(err);
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// ../multicorn-proxy/src/mcp-aggregator.ts
|
|
988
|
+
var PROTOCOL_VERSION = "2024-11-05";
|
|
989
|
+
var UPSTREAM_TIMEOUT_MS = 3e4;
|
|
990
|
+
var STATE_TTL_MS = 5 * 6e4;
|
|
991
|
+
var store = /* @__PURE__ */ new Map();
|
|
992
|
+
function specSignature(upstreams) {
|
|
993
|
+
return upstreams.map((u) => `${u.kind}:${u.url}`).join("|");
|
|
994
|
+
}
|
|
995
|
+
function pruneStore() {
|
|
996
|
+
const now = Date.now();
|
|
997
|
+
for (const [k, v] of store) {
|
|
998
|
+
if (v.expiresAt <= now) store.delete(k);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
function getState(stateKey, upstreams) {
|
|
1002
|
+
pruneStore();
|
|
1003
|
+
const signature = specSignature(upstreams);
|
|
1004
|
+
const existing = store.get(stateKey);
|
|
1005
|
+
if (existing !== void 0 && existing.signature === signature) {
|
|
1006
|
+
existing.expiresAt = Date.now() + STATE_TTL_MS;
|
|
1007
|
+
return existing;
|
|
1008
|
+
}
|
|
1009
|
+
const fresh = {
|
|
1010
|
+
signature,
|
|
1011
|
+
upstreams: upstreams.map((spec) => ({ spec, sessionId: void 0, initialized: false, tools: [] })),
|
|
1012
|
+
toolOwner: /* @__PURE__ */ new Map(),
|
|
1013
|
+
toolsBuilt: false,
|
|
1014
|
+
expiresAt: Date.now() + STATE_TTL_MS
|
|
1015
|
+
};
|
|
1016
|
+
store.set(stateKey, fresh);
|
|
1017
|
+
return fresh;
|
|
1018
|
+
}
|
|
1019
|
+
function baseHeaders(session, apiKey) {
|
|
1020
|
+
const headers = {
|
|
1021
|
+
"content-type": "application/json",
|
|
1022
|
+
accept: "application/json, text/event-stream",
|
|
1023
|
+
"mcp-protocol-version": PROTOCOL_VERSION
|
|
1024
|
+
};
|
|
1025
|
+
if (session.sessionId !== void 0) {
|
|
1026
|
+
headers["mcp-session-id"] = session.sessionId;
|
|
1027
|
+
}
|
|
1028
|
+
if (session.spec.kind === "hosted") {
|
|
1029
|
+
headers["authorization"] = `Bearer ${apiKey}`;
|
|
1030
|
+
}
|
|
1031
|
+
return headers;
|
|
1032
|
+
}
|
|
1033
|
+
function parseRpcResponseText(text, contentType, id) {
|
|
1034
|
+
const candidates = [];
|
|
1035
|
+
if (contentType.includes("text/event-stream")) {
|
|
1036
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1037
|
+
const trimmed = line.trimStart();
|
|
1038
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
1039
|
+
const payload = trimmed.slice(5).trim();
|
|
1040
|
+
if (payload.length === 0 || payload === "[DONE]") continue;
|
|
1041
|
+
try {
|
|
1042
|
+
candidates.push(JSON.parse(payload));
|
|
1043
|
+
} catch {
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
} else if (text.trim().length > 0) {
|
|
1047
|
+
try {
|
|
1048
|
+
candidates.push(JSON.parse(text));
|
|
1049
|
+
} catch {
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
for (const candidate of candidates) {
|
|
1054
|
+
if (typeof candidate === "object" && candidate !== null && "id" in candidate && candidate.id === id && ("result" in candidate || "error" in candidate)) {
|
|
1055
|
+
return candidate;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
for (const candidate of candidates) {
|
|
1059
|
+
if (typeof candidate === "object" && candidate !== null && ("result" in candidate || "error" in candidate)) {
|
|
1060
|
+
return candidate;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
async function postToUpstream(session, rpc, apiKey, signal) {
|
|
1066
|
+
const res = await fetch(session.spec.url, {
|
|
1067
|
+
method: "POST",
|
|
1068
|
+
headers: baseHeaders(session, apiKey),
|
|
1069
|
+
body: JSON.stringify(rpc),
|
|
1070
|
+
signal,
|
|
1071
|
+
redirect: "manual"
|
|
1072
|
+
});
|
|
1073
|
+
const sessionId = res.headers.get("mcp-session-id") ?? void 0;
|
|
1074
|
+
const contentType = (res.headers.get("content-type") ?? "").toLowerCase();
|
|
1075
|
+
const text = await res.text();
|
|
1076
|
+
const id = rpc.id ?? null;
|
|
1077
|
+
const response = parseRpcResponseText(text, contentType, id);
|
|
1078
|
+
return { response, sessionId, ok: res.ok };
|
|
1079
|
+
}
|
|
1080
|
+
var aggRequestId = 0;
|
|
1081
|
+
function nextId() {
|
|
1082
|
+
aggRequestId += 1;
|
|
1083
|
+
return `agg-${String(aggRequestId)}`;
|
|
1084
|
+
}
|
|
1085
|
+
async function ensureInitialized(session, apiKey, signal, logger) {
|
|
1086
|
+
if (session.initialized) return;
|
|
1087
|
+
const initRpc = {
|
|
1088
|
+
jsonrpc: "2.0",
|
|
1089
|
+
id: nextId(),
|
|
1090
|
+
method: "initialize",
|
|
1091
|
+
params: {
|
|
1092
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1093
|
+
capabilities: {},
|
|
1094
|
+
clientInfo: { name: "multicorn-aggregator", version: "1.0.0" }
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
const result = await postToUpstream(session, initRpc, apiKey, signal);
|
|
1098
|
+
if (result.sessionId !== void 0) {
|
|
1099
|
+
session.sessionId = result.sessionId;
|
|
1100
|
+
}
|
|
1101
|
+
try {
|
|
1102
|
+
await postToUpstream(
|
|
1103
|
+
session,
|
|
1104
|
+
{ jsonrpc: "2.0", method: "notifications/initialized" },
|
|
1105
|
+
apiKey,
|
|
1106
|
+
signal
|
|
1107
|
+
);
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
logger?.debug?.("Upstream initialized notification failed (continuing).", {
|
|
1110
|
+
url: session.spec.url,
|
|
1111
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
session.initialized = true;
|
|
1115
|
+
}
|
|
1116
|
+
async function serveBuiltinRpc(serveBuiltin, rpc, logger) {
|
|
1117
|
+
if (serveBuiltin === void 0) return null;
|
|
1118
|
+
try {
|
|
1119
|
+
const text = await serveBuiltin(rpc);
|
|
1120
|
+
if (text === null) return null;
|
|
1121
|
+
const id = rpc.id ?? null;
|
|
1122
|
+
return parseRpcResponseText(text, "application/json", id);
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
logger?.warn("Aggregator built-in serve failed.", {
|
|
1125
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1126
|
+
});
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
async function listUpstreamTools(session, apiKey, signal, serveBuiltin, logger) {
|
|
1131
|
+
let response;
|
|
1132
|
+
if (session.spec.kind === "builtin") {
|
|
1133
|
+
response = await serveBuiltinRpc(
|
|
1134
|
+
serveBuiltin,
|
|
1135
|
+
{ jsonrpc: "2.0", id: nextId(), method: "tools/list", params: {} },
|
|
1136
|
+
logger
|
|
1137
|
+
);
|
|
1138
|
+
} else {
|
|
1139
|
+
await ensureInitialized(session, apiKey, signal, logger);
|
|
1140
|
+
const listRpc = { jsonrpc: "2.0", id: nextId(), method: "tools/list", params: {} };
|
|
1141
|
+
response = (await postToUpstream(session, listRpc, apiKey, signal)).response;
|
|
1142
|
+
}
|
|
1143
|
+
const result = response?.result;
|
|
1144
|
+
const tools = result?.tools;
|
|
1145
|
+
if (!Array.isArray(tools)) return [];
|
|
1146
|
+
const valid = [];
|
|
1147
|
+
for (const tool of tools) {
|
|
1148
|
+
if (typeof tool === "object" && tool !== null && typeof tool.name === "string") {
|
|
1149
|
+
valid.push(tool);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return valid;
|
|
1153
|
+
}
|
|
1154
|
+
async function buildToolMap(state, apiKey, signal, serveBuiltin, logger) {
|
|
1155
|
+
const merged = [];
|
|
1156
|
+
state.toolOwner = /* @__PURE__ */ new Map();
|
|
1157
|
+
for (let i = 0; i < state.upstreams.length; i++) {
|
|
1158
|
+
const session = state.upstreams[i];
|
|
1159
|
+
let tools = [];
|
|
1160
|
+
try {
|
|
1161
|
+
tools = await listUpstreamTools(session, apiKey, signal, serveBuiltin, logger);
|
|
1162
|
+
} catch (err) {
|
|
1163
|
+
logger?.warn("Aggregator could not list tools from upstream.", {
|
|
1164
|
+
url: session.spec.url,
|
|
1165
|
+
kind: session.spec.kind,
|
|
1166
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
session.tools = tools;
|
|
1170
|
+
for (const tool of tools) {
|
|
1171
|
+
if (!state.toolOwner.has(tool.name)) {
|
|
1172
|
+
state.toolOwner.set(tool.name, i);
|
|
1173
|
+
merged.push(tool);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
state.toolsBuilt = true;
|
|
1178
|
+
return merged;
|
|
1179
|
+
}
|
|
1180
|
+
function jsonResponse(id, result) {
|
|
1181
|
+
return JSON.stringify({ jsonrpc: "2.0", id, result });
|
|
1182
|
+
}
|
|
1183
|
+
function jsonError(id, code, message) {
|
|
1184
|
+
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } });
|
|
1185
|
+
}
|
|
1186
|
+
async function handleAggregatedRequest(input) {
|
|
1187
|
+
const { rpc, signal, apiKey, logger } = input;
|
|
1188
|
+
const stateKey = `${input.routingToken}|${input.keyHash}`;
|
|
1189
|
+
const state = getState(stateKey, input.upstreams);
|
|
1190
|
+
const id = rpc.id ?? null;
|
|
1191
|
+
switch (rpc.method) {
|
|
1192
|
+
case "initialize":
|
|
1193
|
+
return jsonResponse(id, {
|
|
1194
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1195
|
+
serverInfo: { name: "multicorn-aggregator", version: "1.0.0" },
|
|
1196
|
+
capabilities: { tools: {} }
|
|
1197
|
+
});
|
|
1198
|
+
case "notifications/initialized":
|
|
1199
|
+
return null;
|
|
1200
|
+
case "ping":
|
|
1201
|
+
return jsonResponse(id, {});
|
|
1202
|
+
case "prompts/list":
|
|
1203
|
+
return jsonResponse(id, { prompts: [] });
|
|
1204
|
+
case "resources/list":
|
|
1205
|
+
return jsonResponse(id, { resources: [] });
|
|
1206
|
+
case "tools/list": {
|
|
1207
|
+
const tools = await buildToolMap(state, apiKey, signal, input.serveBuiltin, logger);
|
|
1208
|
+
return jsonResponse(id, { tools });
|
|
1209
|
+
}
|
|
1210
|
+
case "tools/call": {
|
|
1211
|
+
const params = rpc.params;
|
|
1212
|
+
const toolName = typeof params?.name === "string" ? params.name : void 0;
|
|
1213
|
+
if (toolName === void 0) {
|
|
1214
|
+
return jsonError(id, -32602, "tools/call missing tool name");
|
|
1215
|
+
}
|
|
1216
|
+
if (!state.toolsBuilt) {
|
|
1217
|
+
await buildToolMap(state, apiKey, signal, input.serveBuiltin, logger);
|
|
1218
|
+
}
|
|
1219
|
+
let ownerIndex = state.toolOwner.get(toolName);
|
|
1220
|
+
if (ownerIndex === void 0) {
|
|
1221
|
+
await buildToolMap(state, apiKey, signal, input.serveBuiltin, logger);
|
|
1222
|
+
ownerIndex = state.toolOwner.get(toolName);
|
|
1223
|
+
}
|
|
1224
|
+
if (ownerIndex === void 0) {
|
|
1225
|
+
return jsonError(id, -32601, `Unknown tool: ${toolName}`);
|
|
1226
|
+
}
|
|
1227
|
+
const owner = state.upstreams[ownerIndex];
|
|
1228
|
+
logger?.info("Aggregator routing tool call.", {
|
|
1229
|
+
tool: toolName,
|
|
1230
|
+
upstreamKind: owner.spec.kind,
|
|
1231
|
+
routingToken: input.routingToken
|
|
1232
|
+
});
|
|
1233
|
+
if (owner.spec.kind === "local" || owner.spec.kind === "builtin") {
|
|
1234
|
+
const override = await input.interceptLocal(rpc);
|
|
1235
|
+
if (override !== null) {
|
|
1236
|
+
return override;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
let result;
|
|
1240
|
+
if (owner.spec.kind === "builtin") {
|
|
1241
|
+
result = await serveBuiltinRpc(input.serveBuiltin, rpc, logger);
|
|
1242
|
+
} else {
|
|
1243
|
+
result = await forwardCall(owner, rpc, apiKey, signal, logger);
|
|
1244
|
+
if (result === null && owner.initialized) {
|
|
1245
|
+
owner.initialized = false;
|
|
1246
|
+
owner.sessionId = void 0;
|
|
1247
|
+
await ensureInitialized(owner, apiKey, signal, logger);
|
|
1248
|
+
result = await forwardCall(owner, rpc, apiKey, signal, logger);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (result === null) {
|
|
1252
|
+
return jsonError(id, -32603, `Upstream did not return a response for ${toolName}`);
|
|
1253
|
+
}
|
|
1254
|
+
return JSON.stringify({ ...result, id });
|
|
1255
|
+
}
|
|
1256
|
+
default:
|
|
1257
|
+
return jsonError(id, -32601, `Method not found: ${rpc.method}`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
async function forwardCall(owner, rpc, apiKey, signal, logger) {
|
|
1261
|
+
try {
|
|
1262
|
+
await ensureInitialized(owner, apiKey, signal, logger);
|
|
1263
|
+
const { response } = await postToUpstream(owner, rpc, apiKey, signal);
|
|
1264
|
+
return response;
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
logger?.warn("Aggregator forward failed.", {
|
|
1267
|
+
url: owner.spec.url,
|
|
1268
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1269
|
+
});
|
|
1270
|
+
return null;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
var AGGREGATOR_UPSTREAM_TIMEOUT_MS = UPSTREAM_TIMEOUT_MS;
|
|
1274
|
+
|
|
1275
|
+
// ../multicorn-proxy/src/multicorn-mcp.ts
|
|
1276
|
+
var MULTICORN_MCP_SENTINEL2 = "multicorn://mcp";
|
|
1277
|
+
var GOOGLE_API_TIMEOUT_MS = 15e3;
|
|
1278
|
+
var SAMPLE_NOTE = "\n\nThis is sample data. Connect your Google account in the Shield dashboard to see your real emails, events, and files.";
|
|
1279
|
+
var TOOLS = [
|
|
1280
|
+
{
|
|
1281
|
+
name: "gmail_read_inbox",
|
|
1282
|
+
description: "Read recent emails from your inbox. Optionally filter by query.",
|
|
1283
|
+
inputSchema: {
|
|
1284
|
+
type: "object",
|
|
1285
|
+
properties: {
|
|
1286
|
+
query: { type: "string", description: "Search query to filter emails (Gmail search syntax)" },
|
|
1287
|
+
limit: { type: "number", description: "Maximum number of emails to return (default 5)" }
|
|
1288
|
+
},
|
|
1289
|
+
required: []
|
|
1290
|
+
}
|
|
1291
|
+
},
|
|
1292
|
+
{
|
|
1293
|
+
name: "gmail_send_email",
|
|
1294
|
+
description: "Send an email to a recipient.",
|
|
1295
|
+
inputSchema: {
|
|
1296
|
+
type: "object",
|
|
1297
|
+
properties: {
|
|
1298
|
+
to: { type: "string", description: "Recipient email address" },
|
|
1299
|
+
subject: { type: "string", description: "Email subject line" },
|
|
1300
|
+
body: { type: "string", description: "Email body content" }
|
|
1301
|
+
},
|
|
1302
|
+
required: ["to", "subject", "body"]
|
|
1303
|
+
}
|
|
1304
|
+
},
|
|
1305
|
+
{
|
|
1306
|
+
name: "gmail_delete_thread",
|
|
1307
|
+
description: "Move an email thread to trash.",
|
|
1308
|
+
inputSchema: {
|
|
1309
|
+
type: "object",
|
|
1310
|
+
properties: {
|
|
1311
|
+
thread_id: { type: "string", description: "ID of the thread to trash" }
|
|
1312
|
+
},
|
|
1313
|
+
required: ["thread_id"]
|
|
1314
|
+
}
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
name: "gmail_delete_email",
|
|
1318
|
+
description: "Moves an email to Trash.",
|
|
1319
|
+
inputSchema: {
|
|
1320
|
+
type: "object",
|
|
1321
|
+
properties: {
|
|
1322
|
+
message_id: { type: "string", description: "ID of the email message to move to Trash" }
|
|
1323
|
+
},
|
|
1324
|
+
required: ["message_id"]
|
|
1325
|
+
}
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
name: "calendar_list_events",
|
|
1329
|
+
description: "List calendar events across all of the user's calendars. Defaults to today; optionally filter by date.",
|
|
1330
|
+
inputSchema: {
|
|
1331
|
+
type: "object",
|
|
1332
|
+
properties: {
|
|
1333
|
+
date: { type: "string", description: "Date to list events for (YYYY-MM-DD format)" }
|
|
1334
|
+
},
|
|
1335
|
+
required: []
|
|
1336
|
+
}
|
|
1337
|
+
},
|
|
1338
|
+
{
|
|
1339
|
+
name: "calendar_create_event",
|
|
1340
|
+
description: "Create a new calendar event.",
|
|
1341
|
+
inputSchema: {
|
|
1342
|
+
type: "object",
|
|
1343
|
+
properties: {
|
|
1344
|
+
title: { type: "string", description: "Event title" },
|
|
1345
|
+
date: { type: "string", description: "Event date and time (YYYY-MM-DD or ISO 8601)" },
|
|
1346
|
+
duration_minutes: { type: "number", description: "Duration in minutes (default 60)" },
|
|
1347
|
+
location: { type: "string", description: "Optional event location" }
|
|
1348
|
+
},
|
|
1349
|
+
required: ["title", "date"]
|
|
1350
|
+
}
|
|
1351
|
+
},
|
|
1352
|
+
{
|
|
1353
|
+
name: "calendar_update_event",
|
|
1354
|
+
description: "Update an existing calendar event (title, time, duration, or location).",
|
|
1355
|
+
inputSchema: {
|
|
1356
|
+
type: "object",
|
|
1357
|
+
properties: {
|
|
1358
|
+
event_id: { type: "string", description: "ID of the event to update" },
|
|
1359
|
+
title: { type: "string", description: "New event title" },
|
|
1360
|
+
date: { type: "string", description: "New event date and time (YYYY-MM-DD or ISO 8601)" },
|
|
1361
|
+
duration_minutes: { type: "number", description: "New duration in minutes" },
|
|
1362
|
+
location: { type: "string", description: "New event location" }
|
|
1363
|
+
},
|
|
1364
|
+
required: ["event_id"]
|
|
1365
|
+
}
|
|
1366
|
+
},
|
|
1367
|
+
{
|
|
1368
|
+
name: "calendar_delete_event",
|
|
1369
|
+
description: "Delete a calendar event.",
|
|
1370
|
+
inputSchema: {
|
|
1371
|
+
type: "object",
|
|
1372
|
+
properties: {
|
|
1373
|
+
event_id: { type: "string", description: "ID of the event to delete" }
|
|
1374
|
+
},
|
|
1375
|
+
required: ["event_id"]
|
|
1376
|
+
}
|
|
1377
|
+
},
|
|
1378
|
+
{
|
|
1379
|
+
name: "drive_search_files",
|
|
1380
|
+
description: "Search for files in your drive. Lists recent files when no query is given.",
|
|
1381
|
+
inputSchema: {
|
|
1382
|
+
type: "object",
|
|
1383
|
+
properties: {
|
|
1384
|
+
query: { type: "string", description: "Search query to find files by name" }
|
|
1385
|
+
},
|
|
1386
|
+
required: []
|
|
1387
|
+
}
|
|
1388
|
+
},
|
|
1389
|
+
{
|
|
1390
|
+
name: "drive_write_file",
|
|
1391
|
+
description: "Save a text file to your drive.",
|
|
1392
|
+
inputSchema: {
|
|
1393
|
+
type: "object",
|
|
1394
|
+
properties: {
|
|
1395
|
+
name: { type: "string", description: "File name including extension" },
|
|
1396
|
+
content: { type: "string", description: "File content to save" }
|
|
1397
|
+
},
|
|
1398
|
+
required: ["name", "content"]
|
|
1399
|
+
}
|
|
1400
|
+
},
|
|
1401
|
+
{
|
|
1402
|
+
name: "list_directory",
|
|
1403
|
+
description: "List the files in your sandboxed Multicorn workspace.",
|
|
1404
|
+
inputSchema: {
|
|
1405
|
+
type: "object",
|
|
1406
|
+
properties: {},
|
|
1407
|
+
required: []
|
|
1408
|
+
}
|
|
1409
|
+
},
|
|
1410
|
+
{
|
|
1411
|
+
name: "read_file",
|
|
1412
|
+
description: "Read the contents of a file in your sandboxed Multicorn workspace.",
|
|
1413
|
+
inputSchema: {
|
|
1414
|
+
type: "object",
|
|
1415
|
+
properties: {
|
|
1416
|
+
name: { type: "string", description: "File name to read" }
|
|
1417
|
+
},
|
|
1418
|
+
required: ["name"]
|
|
1419
|
+
}
|
|
1420
|
+
},
|
|
1421
|
+
{
|
|
1422
|
+
name: "write_file",
|
|
1423
|
+
description: "Create or overwrite a file in your sandboxed Multicorn workspace.",
|
|
1424
|
+
inputSchema: {
|
|
1425
|
+
type: "object",
|
|
1426
|
+
properties: {
|
|
1427
|
+
name: { type: "string", description: "File name including extension" },
|
|
1428
|
+
content: { type: "string", description: "File content to save" }
|
|
1429
|
+
},
|
|
1430
|
+
required: ["name", "content"]
|
|
1431
|
+
}
|
|
1432
|
+
},
|
|
1433
|
+
{
|
|
1434
|
+
name: "delete_file",
|
|
1435
|
+
description: "Delete a file from your sandboxed Multicorn workspace.",
|
|
1436
|
+
inputSchema: {
|
|
1437
|
+
type: "object",
|
|
1438
|
+
properties: {
|
|
1439
|
+
name: { type: "string", description: "File name to delete" }
|
|
1440
|
+
},
|
|
1441
|
+
required: ["name"]
|
|
1442
|
+
}
|
|
1443
|
+
},
|
|
1444
|
+
{
|
|
1445
|
+
name: "slack_read_messages",
|
|
1446
|
+
description: "Read recent messages from Slack channels.",
|
|
1447
|
+
inputSchema: {
|
|
1448
|
+
type: "object",
|
|
1449
|
+
properties: {
|
|
1450
|
+
channel: { type: "string", description: "Channel name to read from (without #)" }
|
|
1451
|
+
},
|
|
1452
|
+
required: []
|
|
1453
|
+
}
|
|
1454
|
+
},
|
|
1455
|
+
{
|
|
1456
|
+
name: "slack_send_message",
|
|
1457
|
+
description: "Send a message to a Slack channel.",
|
|
1458
|
+
inputSchema: {
|
|
1459
|
+
type: "object",
|
|
1460
|
+
properties: {
|
|
1461
|
+
channel: { type: "string", description: "Channel name to send to (without #)" },
|
|
1462
|
+
message: { type: "string", description: "Message content" }
|
|
1463
|
+
},
|
|
1464
|
+
required: ["channel", "message"]
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
];
|
|
1468
|
+
function handleToolsList(id) {
|
|
1469
|
+
return {
|
|
1470
|
+
jsonrpc: "2.0",
|
|
1471
|
+
id,
|
|
1472
|
+
result: { tools: TOOLS }
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
async function handleToolCall(id, params, ctx) {
|
|
1476
|
+
if (typeof params !== "object" || params === null) {
|
|
1477
|
+
return { jsonrpc: "2.0", id, error: { code: -32602, message: "Invalid params" } };
|
|
1478
|
+
}
|
|
1479
|
+
const p = params;
|
|
1480
|
+
const toolName = typeof p["name"] === "string" ? p["name"] : "";
|
|
1481
|
+
const args = typeof p["arguments"] === "object" && p["arguments"] !== null ? p["arguments"] : {};
|
|
1482
|
+
const result = await executeToolCall(toolName, args, ctx);
|
|
1483
|
+
if (result === null) {
|
|
1484
|
+
return { jsonrpc: "2.0", id, error: { code: -32602, message: `Unknown tool: ${toolName}` } };
|
|
1485
|
+
}
|
|
1486
|
+
return { jsonrpc: "2.0", id, result };
|
|
1487
|
+
}
|
|
1488
|
+
async function executeToolCall(toolName, args, ctx) {
|
|
1489
|
+
switch (toolName) {
|
|
1490
|
+
case "gmail_read_inbox":
|
|
1491
|
+
return gmailReadInbox(args, ctx);
|
|
1492
|
+
case "gmail_send_email":
|
|
1493
|
+
return gmailSendEmail(args, ctx);
|
|
1494
|
+
case "gmail_delete_thread":
|
|
1495
|
+
return gmailDeleteThread(args, ctx);
|
|
1496
|
+
case "gmail_delete_email":
|
|
1497
|
+
return gmailDeleteEmail(args, ctx);
|
|
1498
|
+
case "calendar_list_events":
|
|
1499
|
+
return calendarListEvents(args, ctx);
|
|
1500
|
+
case "calendar_create_event":
|
|
1501
|
+
return calendarCreateEvent(args, ctx);
|
|
1502
|
+
case "calendar_update_event":
|
|
1503
|
+
return calendarUpdateEvent(args, ctx);
|
|
1504
|
+
case "calendar_delete_event":
|
|
1505
|
+
return calendarDeleteEvent(args, ctx);
|
|
1506
|
+
case "drive_search_files":
|
|
1507
|
+
return driveSearchFiles(args, ctx);
|
|
1508
|
+
case "drive_write_file":
|
|
1509
|
+
return driveWriteFile(args, ctx);
|
|
1510
|
+
case "list_directory":
|
|
1511
|
+
return workspaceList(ctx);
|
|
1512
|
+
case "read_file":
|
|
1513
|
+
return workspaceRead(args, ctx);
|
|
1514
|
+
case "write_file":
|
|
1515
|
+
return workspaceWrite(args, ctx);
|
|
1516
|
+
case "delete_file":
|
|
1517
|
+
return workspaceDelete(args, ctx);
|
|
1518
|
+
case "slack_read_messages":
|
|
1519
|
+
return slackReadMessages(args);
|
|
1520
|
+
case "slack_send_message":
|
|
1521
|
+
return slackSendMessage(args);
|
|
1522
|
+
default:
|
|
1523
|
+
return Promise.resolve(null);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
var GoogleApiError = class extends Error {
|
|
1527
|
+
constructor(status, message) {
|
|
1528
|
+
super(message);
|
|
1529
|
+
this.status = status;
|
|
1530
|
+
this.name = "GoogleApiError";
|
|
1531
|
+
}
|
|
1532
|
+
};
|
|
1533
|
+
function textResult(text) {
|
|
1534
|
+
return { content: [{ type: "text", text }] };
|
|
1535
|
+
}
|
|
1536
|
+
function errorResult(text) {
|
|
1537
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
1538
|
+
}
|
|
1539
|
+
function googleErrorResult(err) {
|
|
1540
|
+
if (err instanceof GoogleApiError) {
|
|
1541
|
+
if (err.status === 401) {
|
|
1542
|
+
return errorResult("Your Google connection has expired. Open your Shield dashboard to reconnect.");
|
|
1543
|
+
}
|
|
1544
|
+
if (err.status === 403) {
|
|
1545
|
+
const detail = err.message.length > 0 ? ` (${err.message})` : "";
|
|
1546
|
+
return errorResult(
|
|
1547
|
+
`Your Google account is missing a required permission${detail}. Reconnect Google in your Shield dashboard and grant the requested access.`
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
return errorResult(`Google API error: ${err.message}`);
|
|
1551
|
+
}
|
|
1552
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1553
|
+
return errorResult(`Could not reach Google: ${message}`);
|
|
1554
|
+
}
|
|
1555
|
+
async function googleRequest(url, auth, init) {
|
|
1556
|
+
const headers = new Headers(init?.headers);
|
|
1557
|
+
headers.set("Authorization", `Bearer ${auth.accessToken}`);
|
|
1558
|
+
const response = await fetch(url, {
|
|
1559
|
+
method: init?.method ?? "GET",
|
|
1560
|
+
headers,
|
|
1561
|
+
body: init?.body,
|
|
1562
|
+
signal: AbortSignal.timeout(GOOGLE_API_TIMEOUT_MS),
|
|
1563
|
+
redirect: "manual"
|
|
1564
|
+
});
|
|
1565
|
+
if (!response.ok) {
|
|
1566
|
+
const raw = await response.text();
|
|
1567
|
+
throw new GoogleApiError(response.status, extractGoogleErrorMessage(raw, response.status));
|
|
1568
|
+
}
|
|
1569
|
+
if (response.status === 204) {
|
|
1570
|
+
return {};
|
|
1571
|
+
}
|
|
1572
|
+
return response.json();
|
|
1573
|
+
}
|
|
1574
|
+
function extractGoogleErrorMessage(raw, status) {
|
|
1575
|
+
try {
|
|
1576
|
+
const parsed = JSON.parse(raw);
|
|
1577
|
+
if (typeof parsed.error === "object" && parsed.error !== null && typeof parsed.error.message === "string") {
|
|
1578
|
+
return parsed.error.message;
|
|
1579
|
+
}
|
|
1580
|
+
if (typeof parsed.error === "string") {
|
|
1581
|
+
return parsed.error;
|
|
1582
|
+
}
|
|
1583
|
+
} catch {
|
|
1584
|
+
}
|
|
1585
|
+
return `HTTP ${String(status)}`;
|
|
1586
|
+
}
|
|
1587
|
+
async function gmailReadInbox(args, ctx) {
|
|
1588
|
+
const query = typeof args["query"] === "string" ? args["query"] : "";
|
|
1589
|
+
const limit = typeof args["limit"] === "number" && args["limit"] > 0 ? Math.floor(args["limit"]) : 5;
|
|
1590
|
+
if (ctx.google === void 0) {
|
|
1591
|
+
return textResult(sampleGmailReadInbox(query, limit) + SAMPLE_NOTE);
|
|
1592
|
+
}
|
|
1593
|
+
try {
|
|
1594
|
+
const listUrl = new URL("https://gmail.googleapis.com/gmail/v1/users/me/messages");
|
|
1595
|
+
listUrl.searchParams.set("maxResults", String(limit));
|
|
1596
|
+
if (query.length > 0) {
|
|
1597
|
+
listUrl.searchParams.set("q", query);
|
|
1598
|
+
}
|
|
1599
|
+
const list = await googleRequest(listUrl.toString(), ctx.google);
|
|
1600
|
+
const messages = list.messages ?? [];
|
|
1601
|
+
if (messages.length === 0) {
|
|
1602
|
+
return textResult("No emails found matching your query.");
|
|
1603
|
+
}
|
|
1604
|
+
const details = await Promise.all(
|
|
1605
|
+
messages.slice(0, limit).map(async (m) => {
|
|
1606
|
+
const metaUrl = new URL(
|
|
1607
|
+
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${encodeURIComponent(m.id)}`
|
|
1608
|
+
);
|
|
1609
|
+
metaUrl.searchParams.set("format", "metadata");
|
|
1610
|
+
for (const h of ["From", "Subject", "Date"]) {
|
|
1611
|
+
metaUrl.searchParams.append("metadataHeaders", h);
|
|
1612
|
+
}
|
|
1613
|
+
return await googleRequest(metaUrl.toString(), ctx.google);
|
|
1614
|
+
})
|
|
1615
|
+
);
|
|
1616
|
+
const text = details.map((msg) => {
|
|
1617
|
+
const headers = msg.payload?.headers ?? [];
|
|
1618
|
+
const from = findHeader(headers, "From") ?? "(unknown sender)";
|
|
1619
|
+
const subject = findHeader(headers, "Subject") ?? "(no subject)";
|
|
1620
|
+
const date = findHeader(headers, "Date") ?? "";
|
|
1621
|
+
const snippet = msg.snippet ?? "";
|
|
1622
|
+
const threadId = msg.threadId !== void 0 && msg.threadId.length > 0 ? `
|
|
1623
|
+
Thread ID: ${msg.threadId}` : "";
|
|
1624
|
+
return `From: ${from}
|
|
1625
|
+
Subject: ${subject}
|
|
1626
|
+
Date: ${date}${threadId}
|
|
1627
|
+
Preview: ${snippet}`;
|
|
1628
|
+
}).join("\n\n");
|
|
1629
|
+
return textResult(text);
|
|
1630
|
+
} catch (err) {
|
|
1631
|
+
return googleErrorResult(err);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
function findHeader(headers, name) {
|
|
1635
|
+
return headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value;
|
|
1636
|
+
}
|
|
1637
|
+
async function gmailSendEmail(args, ctx) {
|
|
1638
|
+
const to = typeof args["to"] === "string" ? args["to"] : "";
|
|
1639
|
+
const subject = typeof args["subject"] === "string" ? args["subject"] : "";
|
|
1640
|
+
const body = typeof args["body"] === "string" ? args["body"] : "";
|
|
1641
|
+
if (ctx.google === void 0) {
|
|
1642
|
+
return textResult(`Email sent to ${to} with subject '${subject}'` + SAMPLE_NOTE);
|
|
1643
|
+
}
|
|
1644
|
+
try {
|
|
1645
|
+
const raw = buildRawEmail(to, subject, body);
|
|
1646
|
+
await googleRequest("https://gmail.googleapis.com/gmail/v1/users/me/messages/send", ctx.google, {
|
|
1647
|
+
method: "POST",
|
|
1648
|
+
headers: { "Content-Type": "application/json" },
|
|
1649
|
+
body: JSON.stringify({ raw })
|
|
1650
|
+
});
|
|
1651
|
+
return textResult(`Email sent to ${to} with subject '${subject}'.`);
|
|
1652
|
+
} catch (err) {
|
|
1653
|
+
return googleErrorResult(err);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
function buildRawEmail(to, subject, body) {
|
|
1657
|
+
const message = [`To: ${to}`, `Subject: ${subject}`, "Content-Type: text/plain; charset=utf-8", "", body].join(
|
|
1658
|
+
"\r\n"
|
|
1659
|
+
);
|
|
1660
|
+
return Buffer.from(message, "utf8").toString("base64url");
|
|
1661
|
+
}
|
|
1662
|
+
async function gmailDeleteThread(args, ctx) {
|
|
1663
|
+
const threadId = typeof args["thread_id"] === "string" ? args["thread_id"] : "";
|
|
1664
|
+
if (threadId.length === 0) {
|
|
1665
|
+
return errorResult("A thread_id is required to delete a thread.");
|
|
1666
|
+
}
|
|
1667
|
+
if (ctx.google === void 0) {
|
|
1668
|
+
return textResult(`Thread '${threadId}' moved to trash` + SAMPLE_NOTE);
|
|
1669
|
+
}
|
|
1670
|
+
try {
|
|
1671
|
+
const url = `https://gmail.googleapis.com/gmail/v1/users/me/threads/${encodeURIComponent(threadId)}/trash`;
|
|
1672
|
+
await googleRequest(url, ctx.google, { method: "POST" });
|
|
1673
|
+
return textResult(`Thread '${threadId}' moved to trash.`);
|
|
1674
|
+
} catch (err) {
|
|
1675
|
+
return googleErrorResult(err);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
async function gmailDeleteEmail(args, ctx) {
|
|
1679
|
+
const messageId = typeof args["message_id"] === "string" ? args["message_id"] : "";
|
|
1680
|
+
if (messageId.length === 0) {
|
|
1681
|
+
return errorResult("A message_id is required to delete an email.");
|
|
1682
|
+
}
|
|
1683
|
+
if (ctx.google === void 0) {
|
|
1684
|
+
return textResult(`Email '${messageId}' moved to Trash` + SAMPLE_NOTE);
|
|
1685
|
+
}
|
|
1686
|
+
try {
|
|
1687
|
+
const url = `https://gmail.googleapis.com/gmail/v1/users/me/messages/${encodeURIComponent(messageId)}/trash`;
|
|
1688
|
+
await googleRequest(url, ctx.google, { method: "POST" });
|
|
1689
|
+
return textResult(`Email '${messageId}' moved to Trash.`);
|
|
1690
|
+
} catch (err) {
|
|
1691
|
+
if (err instanceof GoogleApiError) {
|
|
1692
|
+
if (err.status === 404) {
|
|
1693
|
+
return errorResult("Email not found. The message may have already been deleted or the ID is invalid.");
|
|
1694
|
+
}
|
|
1695
|
+
if (err.status === 403) {
|
|
1696
|
+
return errorResult("Permission denied. The agent does not have write access to Gmail.");
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
return googleErrorResult(err);
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
async function fetchCalendarIds(auth) {
|
|
1703
|
+
const url = "https://www.googleapis.com/calendar/v3/users/me/calendarList?minAccessRole=reader";
|
|
1704
|
+
const data = await googleRequest(url, auth);
|
|
1705
|
+
const items = data.items ?? [];
|
|
1706
|
+
if (items.length === 0) return ["primary"];
|
|
1707
|
+
const selected = items.filter((c) => c.selected !== false).map((c) => c.id);
|
|
1708
|
+
return selected.length > 0 ? selected : ["primary"];
|
|
1709
|
+
}
|
|
1710
|
+
async function fetchCalendarTimezone(auth) {
|
|
1711
|
+
const data = await googleRequest(
|
|
1712
|
+
"https://www.googleapis.com/calendar/v3/calendars/primary",
|
|
1713
|
+
auth
|
|
1714
|
+
);
|
|
1715
|
+
return data.timeZone ?? "UTC";
|
|
1716
|
+
}
|
|
1717
|
+
async function fetchEventsForCalendar(calendarId, start, end, timeZone, auth) {
|
|
1718
|
+
const url = new URL(
|
|
1719
|
+
`https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendarId)}/events`
|
|
1720
|
+
);
|
|
1721
|
+
url.searchParams.set("timeMin", start);
|
|
1722
|
+
url.searchParams.set("timeMax", end);
|
|
1723
|
+
url.searchParams.set("timeZone", timeZone);
|
|
1724
|
+
url.searchParams.set("singleEvents", "true");
|
|
1725
|
+
url.searchParams.set("orderBy", "startTime");
|
|
1726
|
+
const data = await googleRequest(url.toString(), auth);
|
|
1727
|
+
return (data.items ?? []).map((e) => ({
|
|
1728
|
+
...e,
|
|
1729
|
+
_calendarId: calendarId === "primary" ? void 0 : calendarId
|
|
1730
|
+
}));
|
|
1731
|
+
}
|
|
1732
|
+
async function calendarListEvents(args, ctx) {
|
|
1733
|
+
const dateArg = typeof args["date"] === "string" ? args["date"] : void 0;
|
|
1734
|
+
if (ctx.google === void 0) {
|
|
1735
|
+
return textResult(sampleCalendarListEvents() + SAMPLE_NOTE);
|
|
1736
|
+
}
|
|
1737
|
+
try {
|
|
1738
|
+
let timeZone;
|
|
1739
|
+
try {
|
|
1740
|
+
timeZone = await fetchCalendarTimezone(ctx.google);
|
|
1741
|
+
} catch {
|
|
1742
|
+
timeZone = "UTC";
|
|
1743
|
+
}
|
|
1744
|
+
const { start, end } = dayBounds(dateArg, timeZone);
|
|
1745
|
+
let calendarIds;
|
|
1746
|
+
try {
|
|
1747
|
+
calendarIds = await fetchCalendarIds(ctx.google);
|
|
1748
|
+
} catch {
|
|
1749
|
+
calendarIds = ["primary"];
|
|
1750
|
+
}
|
|
1751
|
+
const results = await Promise.allSettled(
|
|
1752
|
+
calendarIds.map((id) => fetchEventsForCalendar(id, start, end, timeZone, ctx.google))
|
|
1753
|
+
);
|
|
1754
|
+
const items = results.filter((r) => r.status === "fulfilled").flatMap((r) => r.value);
|
|
1755
|
+
if (items.length === 0) {
|
|
1756
|
+
const firstError = results.find(
|
|
1757
|
+
(r) => r.status === "rejected"
|
|
1758
|
+
);
|
|
1759
|
+
if (firstError) throw firstError.reason;
|
|
1760
|
+
return textResult("No events found for that day.");
|
|
1761
|
+
}
|
|
1762
|
+
items.sort((a, b) => {
|
|
1763
|
+
const aTime = a.start?.dateTime ?? a.start?.date ?? "";
|
|
1764
|
+
const bTime = b.start?.dateTime ?? b.start?.date ?? "";
|
|
1765
|
+
return aTime.localeCompare(bTime);
|
|
1766
|
+
});
|
|
1767
|
+
const text = items.map((e) => {
|
|
1768
|
+
const summary = e.summary ?? "(no title)";
|
|
1769
|
+
const when = formatEventTime(e);
|
|
1770
|
+
const id = e.id !== void 0 && e.id.length > 0 ? `
|
|
1771
|
+
Event ID: ${e.id}` : "";
|
|
1772
|
+
const location = e.location !== void 0 && e.location.length > 0 ? `
|
|
1773
|
+
Location: ${e.location}` : "";
|
|
1774
|
+
const cal = e._calendarId !== void 0 ? `
|
|
1775
|
+
Calendar: ${e._calendarId}` : "";
|
|
1776
|
+
const attendees = e.attendees !== void 0 && e.attendees.length > 0 ? `
|
|
1777
|
+
Attendees: ${e.attendees.map((a) => a.email).join(", ")}` : "";
|
|
1778
|
+
return `${when} - ${summary}${id}${cal}${location}${attendees}`;
|
|
1779
|
+
}).join("\n");
|
|
1780
|
+
return textResult(text);
|
|
1781
|
+
} catch (err) {
|
|
1782
|
+
return googleErrorResult(err);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
function formatEventTime(e) {
|
|
1786
|
+
const start = e.start?.dateTime ?? e.start?.date ?? "";
|
|
1787
|
+
const end = e.end?.dateTime ?? e.end?.date ?? "";
|
|
1788
|
+
if (start.length === 0) return "(time unknown)";
|
|
1789
|
+
return end.length > 0 ? `${start} to ${end}` : start;
|
|
1790
|
+
}
|
|
1791
|
+
function dayBounds(date, timeZone) {
|
|
1792
|
+
let localDate;
|
|
1793
|
+
if (date !== void 0 && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
1794
|
+
localDate = date;
|
|
1795
|
+
} else {
|
|
1796
|
+
const formatter = new Intl.DateTimeFormat("en-CA", {
|
|
1797
|
+
timeZone,
|
|
1798
|
+
year: "numeric",
|
|
1799
|
+
month: "2-digit",
|
|
1800
|
+
day: "2-digit"
|
|
1801
|
+
});
|
|
1802
|
+
localDate = formatter.format(/* @__PURE__ */ new Date());
|
|
1803
|
+
}
|
|
1804
|
+
const startUtc = localMidnightToUtc(localDate, 0, timeZone);
|
|
1805
|
+
const endUtc = localMidnightToUtc(localDate, 86399999, timeZone);
|
|
1806
|
+
return { start: startUtc, end: endUtc };
|
|
1807
|
+
}
|
|
1808
|
+
function localMidnightToUtc(localDate, msAfterMidnight, timeZone) {
|
|
1809
|
+
const midnightNaive = (/* @__PURE__ */ new Date(`${localDate}T00:00:00Z`)).getTime();
|
|
1810
|
+
const offsetMs = tzOffsetMs(new Date(midnightNaive), timeZone);
|
|
1811
|
+
const midnightUtc = midnightNaive - offsetMs;
|
|
1812
|
+
const verify = tzOffsetMs(new Date(midnightUtc), timeZone);
|
|
1813
|
+
if (verify !== offsetMs) {
|
|
1814
|
+
return new Date(midnightNaive - verify + msAfterMidnight).toISOString();
|
|
1815
|
+
}
|
|
1816
|
+
return new Date(midnightUtc + msAfterMidnight).toISOString();
|
|
1817
|
+
}
|
|
1818
|
+
function tzOffsetMs(instant, timeZone) {
|
|
1819
|
+
const fmt = new Intl.DateTimeFormat("en-US", {
|
|
1820
|
+
timeZone,
|
|
1821
|
+
year: "numeric",
|
|
1822
|
+
month: "2-digit",
|
|
1823
|
+
day: "2-digit",
|
|
1824
|
+
hour: "2-digit",
|
|
1825
|
+
minute: "2-digit",
|
|
1826
|
+
second: "2-digit",
|
|
1827
|
+
hour12: false
|
|
1828
|
+
});
|
|
1829
|
+
const parts = fmt.formatToParts(instant);
|
|
1830
|
+
const p = (type) => parts.find((x) => x.type === type)?.value ?? "0";
|
|
1831
|
+
const h = p("hour") === "24" ? "00" : p("hour");
|
|
1832
|
+
const wall = Date.UTC(
|
|
1833
|
+
Number(p("year")),
|
|
1834
|
+
Number(p("month")) - 1,
|
|
1835
|
+
Number(p("day")),
|
|
1836
|
+
Number(h),
|
|
1837
|
+
Number(p("minute")),
|
|
1838
|
+
Number(p("second"))
|
|
1839
|
+
);
|
|
1840
|
+
return wall - instant.getTime();
|
|
1841
|
+
}
|
|
1842
|
+
async function calendarCreateEvent(args, ctx) {
|
|
1843
|
+
const title = typeof args["title"] === "string" ? args["title"] : "";
|
|
1844
|
+
const date = typeof args["date"] === "string" ? args["date"] : "";
|
|
1845
|
+
const durationMinutes = typeof args["duration_minutes"] === "number" && args["duration_minutes"] > 0 ? Math.floor(args["duration_minutes"]) : 60;
|
|
1846
|
+
const location = typeof args["location"] === "string" ? args["location"] : void 0;
|
|
1847
|
+
if (ctx.google === void 0) {
|
|
1848
|
+
return textResult(`Event '${title}' created for ${date}` + SAMPLE_NOTE);
|
|
1849
|
+
}
|
|
1850
|
+
try {
|
|
1851
|
+
const startDate = parseEventStart(date);
|
|
1852
|
+
const endDate = new Date(startDate.getTime() + durationMinutes * 6e4);
|
|
1853
|
+
const eventBody = {
|
|
1854
|
+
summary: title,
|
|
1855
|
+
start: { dateTime: startDate.toISOString() },
|
|
1856
|
+
end: { dateTime: endDate.toISOString() }
|
|
1857
|
+
};
|
|
1858
|
+
if (location !== void 0 && location.length > 0) {
|
|
1859
|
+
eventBody["location"] = location;
|
|
1860
|
+
}
|
|
1861
|
+
await googleRequest("https://www.googleapis.com/calendar/v3/calendars/primary/events", ctx.google, {
|
|
1862
|
+
method: "POST",
|
|
1863
|
+
headers: { "Content-Type": "application/json" },
|
|
1864
|
+
body: JSON.stringify(eventBody)
|
|
1865
|
+
});
|
|
1866
|
+
return textResult(`Event '${title}' created for ${startDate.toISOString()} (${String(durationMinutes)} min).`);
|
|
1867
|
+
} catch (err) {
|
|
1868
|
+
return googleErrorResult(err);
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
function parseEventStart(date) {
|
|
1872
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
|
1873
|
+
return /* @__PURE__ */ new Date(`${date}T09:00:00.000Z`);
|
|
1874
|
+
}
|
|
1875
|
+
const parsed = new Date(date);
|
|
1876
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
1877
|
+
return /* @__PURE__ */ new Date(`${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}T09:00:00.000Z`);
|
|
1878
|
+
}
|
|
1879
|
+
return parsed;
|
|
1880
|
+
}
|
|
1881
|
+
async function calendarUpdateEvent(args, ctx) {
|
|
1882
|
+
const eventId = typeof args["event_id"] === "string" ? args["event_id"] : "";
|
|
1883
|
+
if (eventId.length === 0) {
|
|
1884
|
+
return errorResult("An event_id is required to update an event.");
|
|
1885
|
+
}
|
|
1886
|
+
const title = typeof args["title"] === "string" ? args["title"] : void 0;
|
|
1887
|
+
const date = typeof args["date"] === "string" ? args["date"] : void 0;
|
|
1888
|
+
const durationMinutes = typeof args["duration_minutes"] === "number" && args["duration_minutes"] > 0 ? Math.floor(args["duration_minutes"]) : void 0;
|
|
1889
|
+
const location = typeof args["location"] === "string" ? args["location"] : void 0;
|
|
1890
|
+
if (ctx.google === void 0) {
|
|
1891
|
+
return textResult(`Event '${eventId}' updated` + SAMPLE_NOTE);
|
|
1892
|
+
}
|
|
1893
|
+
try {
|
|
1894
|
+
const eventBody = {};
|
|
1895
|
+
if (title !== void 0) {
|
|
1896
|
+
eventBody["summary"] = title;
|
|
1897
|
+
}
|
|
1898
|
+
if (location !== void 0) {
|
|
1899
|
+
eventBody["location"] = location;
|
|
1900
|
+
}
|
|
1901
|
+
if (date !== void 0) {
|
|
1902
|
+
const startDate = parseEventStart(date);
|
|
1903
|
+
eventBody["start"] = { dateTime: startDate.toISOString() };
|
|
1904
|
+
const minutes = durationMinutes ?? 60;
|
|
1905
|
+
eventBody["end"] = { dateTime: new Date(startDate.getTime() + minutes * 6e4).toISOString() };
|
|
1906
|
+
}
|
|
1907
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${encodeURIComponent(eventId)}`;
|
|
1908
|
+
await googleRequest(url, ctx.google, {
|
|
1909
|
+
method: "PATCH",
|
|
1910
|
+
headers: { "Content-Type": "application/json" },
|
|
1911
|
+
body: JSON.stringify(eventBody)
|
|
1912
|
+
});
|
|
1913
|
+
return textResult(`Event '${eventId}' updated.`);
|
|
1914
|
+
} catch (err) {
|
|
1915
|
+
return googleErrorResult(err);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
async function calendarDeleteEvent(args, ctx) {
|
|
1919
|
+
const eventId = typeof args["event_id"] === "string" ? args["event_id"] : "";
|
|
1920
|
+
if (eventId.length === 0) {
|
|
1921
|
+
return errorResult("An event_id is required to delete an event.");
|
|
1922
|
+
}
|
|
1923
|
+
if (ctx.google === void 0) {
|
|
1924
|
+
return textResult(`Event '${eventId}' deleted` + SAMPLE_NOTE);
|
|
1925
|
+
}
|
|
1926
|
+
try {
|
|
1927
|
+
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${encodeURIComponent(eventId)}`;
|
|
1928
|
+
await googleRequest(url, ctx.google, { method: "DELETE" });
|
|
1929
|
+
return textResult(`Event '${eventId}' deleted.`);
|
|
1930
|
+
} catch (err) {
|
|
1931
|
+
return googleErrorResult(err);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
async function driveSearchFiles(args, ctx) {
|
|
1935
|
+
const query = typeof args["query"] === "string" ? args["query"] : "";
|
|
1936
|
+
if (ctx.google === void 0) {
|
|
1937
|
+
return textResult(sampleDriveSearchFiles(query) + SAMPLE_NOTE);
|
|
1938
|
+
}
|
|
1939
|
+
try {
|
|
1940
|
+
const url = new URL("https://www.googleapis.com/drive/v3/files");
|
|
1941
|
+
if (query.length > 0) {
|
|
1942
|
+
url.searchParams.set("q", `name contains '${query.replace(/'/g, "\\'")}'`);
|
|
1943
|
+
}
|
|
1944
|
+
url.searchParams.set("fields", "files(id,name,mimeType,modifiedTime,owners,shared)");
|
|
1945
|
+
url.searchParams.set("orderBy", "modifiedTime desc");
|
|
1946
|
+
url.searchParams.set("pageSize", "10");
|
|
1947
|
+
const data = await googleRequest(url.toString(), ctx.google);
|
|
1948
|
+
const files = data.files ?? [];
|
|
1949
|
+
if (files.length === 0) {
|
|
1950
|
+
return textResult("No files found matching your query.");
|
|
1951
|
+
}
|
|
1952
|
+
const text = files.map((f) => {
|
|
1953
|
+
const modified = f.modifiedTime !== void 0 ? ` - modified ${f.modifiedTime}` : "";
|
|
1954
|
+
const shared = f.shared === true ? ", shared" : "";
|
|
1955
|
+
return `${f.name}${modified}${shared}`;
|
|
1956
|
+
}).join("\n");
|
|
1957
|
+
return textResult(text);
|
|
1958
|
+
} catch (err) {
|
|
1959
|
+
return googleErrorResult(err);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
async function driveWriteFile(args, ctx) {
|
|
1963
|
+
const name = typeof args["name"] === "string" ? args["name"] : "";
|
|
1964
|
+
const content = typeof args["content"] === "string" ? args["content"] : "";
|
|
1965
|
+
if (ctx.google === void 0) {
|
|
1966
|
+
return textResult(`File '${name}' saved to Drive` + SAMPLE_NOTE);
|
|
1967
|
+
}
|
|
1968
|
+
try {
|
|
1969
|
+
const boundary = `multicorn-${Math.random().toString(36).slice(2)}`;
|
|
1970
|
+
const metadata = JSON.stringify({ name });
|
|
1971
|
+
const multipartBody = `--${boundary}\r
|
|
1972
|
+
Content-Type: application/json; charset=UTF-8\r
|
|
1973
|
+
\r
|
|
1974
|
+
${metadata}\r
|
|
1975
|
+
--${boundary}\r
|
|
1976
|
+
Content-Type: text/plain\r
|
|
1977
|
+
\r
|
|
1978
|
+
${content}\r
|
|
1979
|
+
--${boundary}--`;
|
|
1980
|
+
await googleRequest("https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart", ctx.google, {
|
|
1981
|
+
method: "POST",
|
|
1982
|
+
headers: { "Content-Type": `multipart/related; boundary=${boundary}` },
|
|
1983
|
+
body: multipartBody
|
|
1984
|
+
});
|
|
1985
|
+
return textResult(`File '${name}' saved to Drive.`);
|
|
1986
|
+
} catch (err) {
|
|
1987
|
+
return googleErrorResult(err);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
function slackReadMessages(args) {
|
|
1991
|
+
const messages = [
|
|
1992
|
+
{ channel: "general", author: "Sam Patel", text: "Launch is confirmed for next Tuesday. Marketing assets are ready." },
|
|
1993
|
+
{ channel: "engineering", author: "Alex Chen", text: "PR #247 merged. Deployment starts at 4pm." },
|
|
1994
|
+
{ channel: "random", author: "Jordan Kim", text: "Friday social is at the rooftop bar this week!" }
|
|
1995
|
+
];
|
|
1996
|
+
const channel = typeof args["channel"] === "string" ? args["channel"].toLowerCase().replace(/^#/, "") : "";
|
|
1997
|
+
let filtered = messages;
|
|
1998
|
+
if (channel.length > 0) {
|
|
1999
|
+
filtered = messages.filter((m) => m.channel === channel);
|
|
2000
|
+
}
|
|
2001
|
+
const text = filtered.length === 0 ? `No recent messages in #${channel}.` : filtered.map((m) => `#${m.channel} \u2014 ${m.author}: "${m.text}"`).join("\n");
|
|
2002
|
+
return Promise.resolve(textResult(text));
|
|
2003
|
+
}
|
|
2004
|
+
function slackSendMessage(args) {
|
|
2005
|
+
const channel = typeof args["channel"] === "string" ? args["channel"].replace(/^#/, "") : "";
|
|
2006
|
+
return Promise.resolve(textResult(`Message sent to #${channel}`));
|
|
2007
|
+
}
|
|
2008
|
+
function sampleGmailReadInbox(query, limit) {
|
|
2009
|
+
const emails = [
|
|
2010
|
+
{
|
|
2011
|
+
from: "Alex Chen <alex@acme.co>",
|
|
2012
|
+
subject: "Q3 planning doc ready for review",
|
|
2013
|
+
date: "yesterday",
|
|
2014
|
+
threadId: "thread_sample_q3",
|
|
2015
|
+
preview: "Hey team, I've finished the Q3 planning document..."
|
|
2016
|
+
},
|
|
2017
|
+
{
|
|
2018
|
+
from: "Jordan Kim <jordan@acme.co>",
|
|
2019
|
+
subject: "Design review at 2pm",
|
|
2020
|
+
date: "today",
|
|
2021
|
+
threadId: "thread_sample_design",
|
|
2022
|
+
preview: "Quick reminder about the design review..."
|
|
2023
|
+
},
|
|
2024
|
+
{
|
|
2025
|
+
from: "Acme Weekly <newsletter@acme.co>",
|
|
2026
|
+
subject: "This week at Acme",
|
|
2027
|
+
date: "2 days ago",
|
|
2028
|
+
threadId: "thread_sample_weekly",
|
|
2029
|
+
preview: "Product launch update, new hires, and Friday social..."
|
|
2030
|
+
}
|
|
2031
|
+
];
|
|
2032
|
+
const q = query.toLowerCase();
|
|
2033
|
+
let filtered = emails;
|
|
2034
|
+
if (q.length > 0) {
|
|
2035
|
+
filtered = emails.filter(
|
|
2036
|
+
(e) => e.subject.toLowerCase().includes(q) || e.from.toLowerCase().includes(q) || e.preview.toLowerCase().includes(q)
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
2039
|
+
filtered = filtered.slice(0, limit);
|
|
2040
|
+
return filtered.length === 0 ? "No emails found matching your query." : filtered.map(
|
|
2041
|
+
(e) => `From: ${e.from}
|
|
2042
|
+
Subject: ${e.subject}
|
|
2043
|
+
Date: ${e.date}
|
|
2044
|
+
Thread ID: ${e.threadId}
|
|
2045
|
+
Preview: ${e.preview}`
|
|
2046
|
+
).join("\n\n");
|
|
2047
|
+
}
|
|
2048
|
+
function sampleCalendarListEvents() {
|
|
2049
|
+
return [
|
|
2050
|
+
"9:00 AM \u2014 Daily standup (15 min, recurring) [Event ID: evt_sample_standup]",
|
|
2051
|
+
"11:00 AM \u2014 1:1 with Alex Chen (30 min) [Event ID: evt_sample_1on1]",
|
|
2052
|
+
"12:30 PM \u2014 Team lunch at Sushi Place (60 min) [Event ID: evt_sample_lunch]",
|
|
2053
|
+
"3:00 PM \u2014 Sprint demo (45 min, Friday) [Event ID: evt_sample_demo]"
|
|
2054
|
+
].join("\n");
|
|
2055
|
+
}
|
|
2056
|
+
function sampleDriveSearchFiles(query) {
|
|
2057
|
+
const files = [
|
|
2058
|
+
{ name: "Q3 Planning.docx", modified: "Modified yesterday", shared: "shared with team" },
|
|
2059
|
+
{ name: "Budget 2026.xlsx", modified: "Modified 3 days ago", shared: "shared with finance" },
|
|
2060
|
+
{ name: "Meeting Notes - Sprint Review.md", modified: "Modified today", shared: "" },
|
|
2061
|
+
{ name: "Product Roadmap.pdf", modified: "Modified last week", shared: "shared with leadership" },
|
|
2062
|
+
{ name: "Onboarding Checklist.docx", modified: "Modified 2 weeks ago", shared: "" }
|
|
2063
|
+
];
|
|
2064
|
+
const q = query.toLowerCase();
|
|
2065
|
+
let filtered = files;
|
|
2066
|
+
if (q.length > 0) {
|
|
2067
|
+
filtered = files.filter((f) => f.name.toLowerCase().includes(q));
|
|
2068
|
+
}
|
|
2069
|
+
return filtered.length === 0 ? "No files found matching your query." : filtered.map((f) => `${f.name} \u2014 ${f.modified}${f.shared ? `, ${f.shared}` : ""}`).join("\n");
|
|
2070
|
+
}
|
|
2071
|
+
var WORKSPACE_TIMEOUT_MS = 1e4;
|
|
2072
|
+
var WORKSPACE_AUTH_HEADER = "X-Multicorn-Key";
|
|
2073
|
+
var WORKSPACE_UNAVAILABLE = "Your Multicorn workspace is temporarily unavailable. Please try again in a moment.";
|
|
2074
|
+
var WorkspaceApiError = class extends Error {
|
|
2075
|
+
};
|
|
2076
|
+
async function workspaceRequest(ws, path, init) {
|
|
2077
|
+
const base = ws.baseUrl.replace(/\/+$/, "");
|
|
2078
|
+
const headers = { [WORKSPACE_AUTH_HEADER]: ws.apiKey };
|
|
2079
|
+
if (init?.body !== void 0) {
|
|
2080
|
+
headers["Content-Type"] = "application/json";
|
|
2081
|
+
}
|
|
2082
|
+
const response = await fetch(`${base}/api/v1/workspace/files${path}`, {
|
|
2083
|
+
method: init?.method ?? "GET",
|
|
2084
|
+
headers,
|
|
2085
|
+
body: init?.body,
|
|
2086
|
+
signal: AbortSignal.timeout(WORKSPACE_TIMEOUT_MS),
|
|
2087
|
+
redirect: "manual"
|
|
2088
|
+
});
|
|
2089
|
+
if (response.status === 404) {
|
|
2090
|
+
throw new WorkspaceApiError("not_found");
|
|
2091
|
+
}
|
|
2092
|
+
if (!response.ok) {
|
|
2093
|
+
throw new WorkspaceApiError(`Workspace API error: HTTP ${String(response.status)}`);
|
|
2094
|
+
}
|
|
2095
|
+
if (response.status === 204) {
|
|
2096
|
+
return {};
|
|
2097
|
+
}
|
|
2098
|
+
return response.json();
|
|
2099
|
+
}
|
|
2100
|
+
function unwrapData(json) {
|
|
2101
|
+
if (typeof json !== "object" || json === null) return null;
|
|
2102
|
+
const obj = json;
|
|
2103
|
+
const data = obj["data"];
|
|
2104
|
+
if (typeof data !== "object" || data === null) return null;
|
|
2105
|
+
return data;
|
|
2106
|
+
}
|
|
2107
|
+
function encodeFileName(name) {
|
|
2108
|
+
return encodeURIComponent(name.trim());
|
|
2109
|
+
}
|
|
2110
|
+
async function workspaceList(ctx) {
|
|
2111
|
+
if (ctx.workspace === void 0) {
|
|
2112
|
+
return textResult("Your Multicorn workspace is empty.");
|
|
2113
|
+
}
|
|
2114
|
+
try {
|
|
2115
|
+
const json = await workspaceRequest(ctx.workspace, "");
|
|
2116
|
+
const data = unwrapData(json);
|
|
2117
|
+
const files = Array.isArray(data?.["files"]) ? data["files"] : [];
|
|
2118
|
+
if (files.length === 0) {
|
|
2119
|
+
return textResult("Your Multicorn workspace has no files yet.");
|
|
2120
|
+
}
|
|
2121
|
+
const lines = files.map((f) => {
|
|
2122
|
+
const size = typeof f.size === "number" ? ` (${String(f.size)} bytes)` : "";
|
|
2123
|
+
return `${f.name}${size}`;
|
|
2124
|
+
});
|
|
2125
|
+
return textResult(`Files in your Multicorn workspace:
|
|
2126
|
+
${lines.join("\n")}`);
|
|
2127
|
+
} catch {
|
|
2128
|
+
return errorResult(WORKSPACE_UNAVAILABLE);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
async function workspaceRead(args, ctx) {
|
|
2132
|
+
const name = typeof args["name"] === "string" ? args["name"].trim() : "";
|
|
2133
|
+
if (name.length === 0) {
|
|
2134
|
+
return errorResult("A file name is required.");
|
|
2135
|
+
}
|
|
2136
|
+
if (ctx.workspace === void 0) {
|
|
2137
|
+
return errorResult(`No file named "${name}" in your Multicorn workspace.`);
|
|
2138
|
+
}
|
|
2139
|
+
try {
|
|
2140
|
+
const json = await workspaceRequest(ctx.workspace, `/${encodeFileName(name)}`);
|
|
2141
|
+
const data = unwrapData(json);
|
|
2142
|
+
const content = typeof data?.["content"] === "string" ? data["content"] : "";
|
|
2143
|
+
return textResult(`Contents of ${name}:
|
|
2144
|
+
|
|
2145
|
+
${content}`);
|
|
2146
|
+
} catch (err) {
|
|
2147
|
+
if (err instanceof WorkspaceApiError && err.message === "not_found") {
|
|
2148
|
+
return errorResult(`No file named "${name}" in your Multicorn workspace.`);
|
|
2149
|
+
}
|
|
2150
|
+
return errorResult(WORKSPACE_UNAVAILABLE);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
async function workspaceWrite(args, ctx) {
|
|
2154
|
+
const name = typeof args["name"] === "string" ? args["name"].trim() : "";
|
|
2155
|
+
const content = typeof args["content"] === "string" ? args["content"] : "";
|
|
2156
|
+
if (name.length === 0) {
|
|
2157
|
+
return errorResult("A file name is required.");
|
|
2158
|
+
}
|
|
2159
|
+
if (ctx.workspace === void 0) {
|
|
2160
|
+
return errorResult(WORKSPACE_UNAVAILABLE);
|
|
2161
|
+
}
|
|
2162
|
+
try {
|
|
2163
|
+
await workspaceRequest(ctx.workspace, `/${encodeFileName(name)}`, {
|
|
2164
|
+
method: "PUT",
|
|
2165
|
+
body: JSON.stringify({ content })
|
|
2166
|
+
});
|
|
2167
|
+
return textResult(`Saved "${name}" to your Multicorn workspace.`);
|
|
2168
|
+
} catch {
|
|
2169
|
+
return errorResult(WORKSPACE_UNAVAILABLE);
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
async function workspaceDelete(args, ctx) {
|
|
2173
|
+
const name = typeof args["name"] === "string" ? args["name"].trim() : "";
|
|
2174
|
+
if (name.length === 0) {
|
|
2175
|
+
return errorResult("A file name is required.");
|
|
2176
|
+
}
|
|
2177
|
+
if (ctx.workspace === void 0) {
|
|
2178
|
+
return errorResult(WORKSPACE_UNAVAILABLE);
|
|
2179
|
+
}
|
|
2180
|
+
try {
|
|
2181
|
+
await workspaceRequest(ctx.workspace, `/${encodeFileName(name)}`, { method: "DELETE" });
|
|
2182
|
+
return textResult(`Deleted "${name}" from your Multicorn workspace.`);
|
|
2183
|
+
} catch (err) {
|
|
2184
|
+
if (err instanceof WorkspaceApiError && err.message === "not_found") {
|
|
2185
|
+
return errorResult(`No file named "${name}" in your Multicorn workspace.`);
|
|
2186
|
+
}
|
|
2187
|
+
return errorResult(WORKSPACE_UNAVAILABLE);
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
var MCP_TOOL_SCOPES = {
|
|
2191
|
+
gmail_read_inbox: { service: "gmail", permissionLevel: "read" },
|
|
2192
|
+
gmail_send_email: { service: "gmail", permissionLevel: "write" },
|
|
2193
|
+
gmail_delete_thread: { service: "gmail", permissionLevel: "write" },
|
|
2194
|
+
gmail_delete_email: { service: "gmail", permissionLevel: "write" },
|
|
2195
|
+
calendar_list_events: { service: "calendar", permissionLevel: "read" },
|
|
2196
|
+
calendar_create_event: { service: "calendar", permissionLevel: "write" },
|
|
2197
|
+
calendar_update_event: { service: "calendar", permissionLevel: "write" },
|
|
2198
|
+
calendar_delete_event: { service: "calendar", permissionLevel: "write" },
|
|
2199
|
+
drive_search_files: { service: "drive", permissionLevel: "read" },
|
|
2200
|
+
drive_write_file: { service: "drive", permissionLevel: "write" },
|
|
2201
|
+
// Hosted workspace filesystem. Delete is its own scope - granting write to
|
|
2202
|
+
// save a file must never imply the right to delete one.
|
|
2203
|
+
list_directory: { service: "filesystem", permissionLevel: "read" },
|
|
2204
|
+
read_file: { service: "filesystem", permissionLevel: "read" },
|
|
2205
|
+
write_file: { service: "filesystem", permissionLevel: "write" },
|
|
2206
|
+
delete_file: { service: "filesystem", permissionLevel: "delete" },
|
|
2207
|
+
slack_read_messages: { service: "slack", permissionLevel: "read" },
|
|
2208
|
+
slack_send_message: { service: "slack", permissionLevel: "write" }
|
|
2209
|
+
};
|
|
2210
|
+
function getMcpToolScope(toolName) {
|
|
2211
|
+
const name = toolName.trim();
|
|
2212
|
+
const direct = MCP_TOOL_SCOPES[name];
|
|
2213
|
+
if (direct !== void 0) return direct;
|
|
2214
|
+
const dotIndex = name.lastIndexOf(".");
|
|
2215
|
+
if (dotIndex !== -1) {
|
|
2216
|
+
return MCP_TOOL_SCOPES[name.slice(dotIndex + 1)];
|
|
2217
|
+
}
|
|
2218
|
+
return void 0;
|
|
2219
|
+
}
|
|
2220
|
+
async function handleMulticornMcpRequest(rpc, ctx = {}) {
|
|
2221
|
+
if (rpc.method === "tools/list") {
|
|
2222
|
+
return JSON.stringify(handleToolsList(rpc.id));
|
|
2223
|
+
}
|
|
2224
|
+
if (rpc.method === "tools/call") {
|
|
2225
|
+
return JSON.stringify(await handleToolCall(rpc.id, rpc.params, ctx));
|
|
2226
|
+
}
|
|
2227
|
+
if (rpc.method === "initialize") {
|
|
2228
|
+
return JSON.stringify({
|
|
2229
|
+
jsonrpc: "2.0",
|
|
2230
|
+
id: rpc.id,
|
|
2231
|
+
result: {
|
|
2232
|
+
protocolVersion: "2024-11-05",
|
|
2233
|
+
serverInfo: { name: "multicorn-mcp", version: "1.0.0" },
|
|
2234
|
+
capabilities: { tools: {} }
|
|
2235
|
+
}
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
if (rpc.method === "notifications/initialized") {
|
|
2239
|
+
return null;
|
|
2240
|
+
}
|
|
2241
|
+
if (rpc.method === "prompts/list" || rpc.method === "resources/list") {
|
|
2242
|
+
return JSON.stringify({
|
|
2243
|
+
jsonrpc: "2.0",
|
|
2244
|
+
id: rpc.id,
|
|
2245
|
+
result: rpc.method === "prompts/list" ? { prompts: [] } : { resources: [] }
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
return JSON.stringify({
|
|
2249
|
+
jsonrpc: "2.0",
|
|
2250
|
+
id: rpc.id,
|
|
2251
|
+
error: { code: -32601, message: `Method not found: ${rpc.method}` }
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// ../multicorn-proxy/src/tool-call-intercept.ts
|
|
2256
|
+
var ACTION_CHECK_TIMEOUT_MS = 8e3;
|
|
2257
|
+
var AUTH_HEADER = "X-Multicorn-Key";
|
|
2258
|
+
var AUDIT_LOG_TIMEOUT_MS = 5e3;
|
|
2259
|
+
var AUDIT_LOG_MAX_ATTEMPTS = 3;
|
|
2260
|
+
var CANONICAL_SERVICE = {
|
|
2261
|
+
google_calendar: "calendar",
|
|
2262
|
+
google_drive: "drive"
|
|
2263
|
+
};
|
|
2264
|
+
function canonicaliseService(service) {
|
|
2265
|
+
return CANONICAL_SERVICE[service] ?? service;
|
|
2266
|
+
}
|
|
2267
|
+
function connectedServicesFromGoogleScopes(grantedScopes) {
|
|
2268
|
+
if (grantedScopes === void 0 || grantedScopes.length === 0) {
|
|
2269
|
+
return [];
|
|
2270
|
+
}
|
|
2271
|
+
const lower = grantedScopes.toLowerCase();
|
|
2272
|
+
const services = [];
|
|
2273
|
+
if (lower.includes("gmail") || lower.includes("mail.google")) {
|
|
2274
|
+
services.push("gmail");
|
|
2275
|
+
}
|
|
2276
|
+
if (lower.includes("calendar")) {
|
|
2277
|
+
services.push("calendar");
|
|
2278
|
+
}
|
|
2279
|
+
if (lower.includes("drive")) {
|
|
2280
|
+
services.push("drive");
|
|
2281
|
+
}
|
|
2282
|
+
return services;
|
|
2283
|
+
}
|
|
2284
|
+
function buildConsentUrl(agentName, service, permissionLevel, dashboardUrl, platform, _connectedServices = []) {
|
|
2285
|
+
const base = dashboardUrl.replace(/\/+$/, "");
|
|
2286
|
+
const params = new URLSearchParams({ agent: agentName });
|
|
2287
|
+
params.set("scopes", `${service}:${permissionLevel}`);
|
|
2288
|
+
if (platform) {
|
|
2289
|
+
params.set("platform", platform);
|
|
2290
|
+
}
|
|
2291
|
+
return `${base}/consent?${params.toString()}`;
|
|
2292
|
+
}
|
|
2293
|
+
function buildPendingApprovalResponse(id, service, permissionLevel, _dashboardUrl, _agentName, consentUrl) {
|
|
2294
|
+
return {
|
|
2295
|
+
jsonrpc: "2.0",
|
|
2296
|
+
id,
|
|
2297
|
+
result: {
|
|
2298
|
+
// isError marks this as a tool-level failure. Many MCP clients skip the
|
|
2299
|
+
// outputSchema/structuredContent check on error results; structuredContent
|
|
2300
|
+
// is also included so clients that always enforce the schema still accept
|
|
2301
|
+
// it. Together this keeps the consent link from being rejected as -32600
|
|
2302
|
+
// for upstream tools that declare an output schema (e.g. server-filesystem).
|
|
2303
|
+
isError: true,
|
|
2304
|
+
content: [
|
|
2305
|
+
{
|
|
2306
|
+
type: "text",
|
|
2307
|
+
text: `Permission required
|
|
2308
|
+
|
|
2309
|
+
This tool needs approval before it can be used. Open this link to grant access:
|
|
2310
|
+
|
|
2311
|
+
${consentUrl}
|
|
2312
|
+
|
|
2313
|
+
After approving, try your request again.`
|
|
2314
|
+
}
|
|
2315
|
+
],
|
|
2316
|
+
structuredContent: {
|
|
2317
|
+
multicornConsentRequired: true,
|
|
2318
|
+
service,
|
|
2319
|
+
permissionLevel,
|
|
2320
|
+
consentUrl
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
function buildMutationPermissionRequiredResponse(id, service, level, consentUrl) {
|
|
2326
|
+
const label = level === "delete" ? "Delete" : "Write";
|
|
2327
|
+
return {
|
|
2328
|
+
jsonrpc: "2.0",
|
|
2329
|
+
id,
|
|
2330
|
+
result: {
|
|
2331
|
+
// See buildPendingApprovalResponse: isError + structuredContent keep this
|
|
2332
|
+
// consent result valid for upstream tools that declare an output schema,
|
|
2333
|
+
// so the link surfaces instead of failing as MCP error -32600.
|
|
2334
|
+
isError: true,
|
|
2335
|
+
content: [
|
|
2336
|
+
{
|
|
2337
|
+
type: "text",
|
|
2338
|
+
text: `${label} permission required
|
|
2339
|
+
|
|
2340
|
+
This action needs ${level} permission for ${service}, which has not been granted. ` + `${level === "delete" ? "Write access does not include delete." : ""}`.trim() + `
|
|
2341
|
+
|
|
2342
|
+
Grant ${level} access here:
|
|
2343
|
+
|
|
2344
|
+
${consentUrl}
|
|
2345
|
+
|
|
2346
|
+
After granting, try your request again.`
|
|
2347
|
+
}
|
|
2348
|
+
],
|
|
2349
|
+
structuredContent: {
|
|
2350
|
+
multicornConsentRequired: true,
|
|
2351
|
+
service,
|
|
2352
|
+
permissionLevel: level,
|
|
2353
|
+
consentUrl
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
async function checkActionPermission(payload, apiKey, baseUrl) {
|
|
2359
|
+
try {
|
|
2360
|
+
const response = await fetch(`${baseUrl}/api/v1/actions`, {
|
|
2361
|
+
method: "POST",
|
|
2362
|
+
headers: {
|
|
2363
|
+
"Content-Type": "application/json",
|
|
2364
|
+
[AUTH_HEADER]: apiKey
|
|
2365
|
+
},
|
|
2366
|
+
body: JSON.stringify(payload),
|
|
2367
|
+
signal: AbortSignal.timeout(ACTION_CHECK_TIMEOUT_MS),
|
|
2368
|
+
redirect: "manual"
|
|
2369
|
+
});
|
|
2370
|
+
if (response.status === 201) {
|
|
2371
|
+
return { status: "approved" };
|
|
2372
|
+
}
|
|
2373
|
+
if (response.status === 202) {
|
|
2374
|
+
let body;
|
|
2375
|
+
try {
|
|
2376
|
+
body = await response.json();
|
|
2377
|
+
} catch {
|
|
2378
|
+
return { status: "blocked" };
|
|
2379
|
+
}
|
|
2380
|
+
if (typeof body !== "object" || body === null) return { status: "blocked" };
|
|
2381
|
+
const obj = body;
|
|
2382
|
+
if (obj["success"] !== true) return { status: "blocked" };
|
|
2383
|
+
const data = obj["data"];
|
|
2384
|
+
if (typeof data !== "object" || data === null) return { status: "blocked" };
|
|
2385
|
+
const dataObj = data;
|
|
2386
|
+
if (dataObj["status"] === "approved") {
|
|
2387
|
+
return { status: "approved" };
|
|
2388
|
+
}
|
|
2389
|
+
const approvalId = dataObj["approval_id"];
|
|
2390
|
+
if (typeof approvalId !== "string") return { status: "blocked" };
|
|
2391
|
+
return { status: "pending", approvalId };
|
|
2392
|
+
}
|
|
2393
|
+
return { status: "blocked" };
|
|
2394
|
+
} catch {
|
|
2395
|
+
return { status: "blocked" };
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
function sleep(ms) {
|
|
2399
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2400
|
+
}
|
|
2401
|
+
function auditBackoffMs(attempt) {
|
|
2402
|
+
return 150 * Math.pow(3, attempt);
|
|
2403
|
+
}
|
|
2404
|
+
async function recordAuditAction(payload, apiKey, baseUrl, logger) {
|
|
2405
|
+
let lastError;
|
|
2406
|
+
logger?.info("Recording action to audit log.", {
|
|
2407
|
+
agent: payload.agent,
|
|
2408
|
+
service: payload.service,
|
|
2409
|
+
actionType: payload.actionType,
|
|
2410
|
+
status: payload.status
|
|
2411
|
+
});
|
|
2412
|
+
for (let attempt = 0; attempt < AUDIT_LOG_MAX_ATTEMPTS; attempt++) {
|
|
2413
|
+
try {
|
|
2414
|
+
const response = await fetch(`${baseUrl}/api/v1/actions`, {
|
|
2415
|
+
method: "POST",
|
|
2416
|
+
headers: {
|
|
2417
|
+
"Content-Type": "application/json",
|
|
2418
|
+
[AUTH_HEADER]: apiKey
|
|
2419
|
+
},
|
|
2420
|
+
body: JSON.stringify(payload),
|
|
2421
|
+
signal: AbortSignal.timeout(AUDIT_LOG_TIMEOUT_MS),
|
|
2422
|
+
redirect: "manual"
|
|
2423
|
+
});
|
|
2424
|
+
if (response.ok) {
|
|
2425
|
+
logger?.info("Action recorded to audit log.", {
|
|
2426
|
+
agent: payload.agent,
|
|
2427
|
+
service: payload.service,
|
|
2428
|
+
actionType: payload.actionType,
|
|
2429
|
+
status: payload.status,
|
|
2430
|
+
httpStatus: response.status
|
|
2431
|
+
});
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
if (response.status >= 400 && response.status < 500) {
|
|
2435
|
+
lastError = new Error(`audit log POST rejected with client error ${String(response.status)}`);
|
|
2436
|
+
break;
|
|
2437
|
+
}
|
|
2438
|
+
lastError = new Error(`audit log POST failed with status ${String(response.status)}`);
|
|
2439
|
+
} catch (error) {
|
|
2440
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
2441
|
+
}
|
|
2442
|
+
if (attempt < AUDIT_LOG_MAX_ATTEMPTS - 1) {
|
|
2443
|
+
await sleep(auditBackoffMs(attempt));
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
logger?.error("Audit log write dropped after retries.", {
|
|
2447
|
+
auditLogDropped: true,
|
|
2448
|
+
agent: payload.agent,
|
|
2449
|
+
service: payload.service,
|
|
2450
|
+
actionType: payload.actionType,
|
|
2451
|
+
status: payload.status,
|
|
2452
|
+
attempts: AUDIT_LOG_MAX_ATTEMPTS,
|
|
2453
|
+
error: lastError?.message ?? "unknown error"
|
|
2454
|
+
});
|
|
2455
|
+
}
|
|
2456
|
+
async function maybeInterceptToolsCall(input) {
|
|
2457
|
+
if (input.rpc.method !== "tools/call") {
|
|
2458
|
+
return null;
|
|
2459
|
+
}
|
|
2460
|
+
const toolParams = extractToolCallParams(input.rpc);
|
|
2461
|
+
if (toolParams === null) {
|
|
2462
|
+
return null;
|
|
2463
|
+
}
|
|
2464
|
+
try {
|
|
2465
|
+
const cacheKey = `${input.routingToken}|${input.keyHash}`;
|
|
2466
|
+
let agentId;
|
|
2467
|
+
let grantedScopes;
|
|
2468
|
+
const agentName = input.agentName ?? input.serverName;
|
|
2469
|
+
input.logger?.info("Intercepting tool call.", {
|
|
2470
|
+
tool: toolParams.name,
|
|
2471
|
+
agentName,
|
|
2472
|
+
serverName: input.serverName,
|
|
2473
|
+
routingToken: input.routingToken
|
|
2474
|
+
});
|
|
2475
|
+
const cached = input.scopeCache.get(cacheKey);
|
|
2476
|
+
if (cached !== null) {
|
|
2477
|
+
agentId = cached.agentId;
|
|
2478
|
+
grantedScopes = cached.grantedScopes;
|
|
2479
|
+
} else {
|
|
2480
|
+
const resolved = await resolveAgentAndScopes(
|
|
2481
|
+
agentName,
|
|
2482
|
+
input.apiKey,
|
|
2483
|
+
input.baseUrl,
|
|
2484
|
+
input.platform
|
|
2485
|
+
);
|
|
2486
|
+
if (resolved.kind === "auth") {
|
|
2487
|
+
return JSON.stringify(buildAuthErrorResponse(input.rpc.id));
|
|
2488
|
+
}
|
|
2489
|
+
if (resolved.kind === "unreachable") {
|
|
2490
|
+
return JSON.stringify(buildServiceUnreachableResponse(input.rpc.id, input.dashboardUrl));
|
|
2491
|
+
}
|
|
2492
|
+
agentId = resolved.agentId;
|
|
2493
|
+
grantedScopes = resolved.scopes;
|
|
2494
|
+
input.scopeCache.set(cacheKey, { agentId, grantedScopes });
|
|
2495
|
+
}
|
|
2496
|
+
if (agentId.length === 0) {
|
|
2497
|
+
return JSON.stringify(buildServiceUnreachableResponse(input.rpc.id, input.dashboardUrl));
|
|
2498
|
+
}
|
|
2499
|
+
const baseMapping = mapMcpToolToScope(toolParams.name);
|
|
2500
|
+
const builtInScope = getMcpToolScope(toolParams.name);
|
|
2501
|
+
const mapping = {
|
|
2502
|
+
service: builtInScope?.service ?? canonicaliseService(baseMapping.service),
|
|
2503
|
+
permissionLevel: builtInScope?.permissionLevel ?? baseMapping.permissionLevel,
|
|
2504
|
+
actionType: baseMapping.actionType
|
|
2505
|
+
};
|
|
2506
|
+
const requested = {
|
|
2507
|
+
service: mapping.service,
|
|
2508
|
+
permissionLevel: mapping.permissionLevel
|
|
2509
|
+
};
|
|
2510
|
+
const connectedServices = connectedServicesFromGoogleScopes(input.googleGrantedScopes);
|
|
2511
|
+
let validation = validateScopeAccess(grantedScopes, requested);
|
|
2512
|
+
if (!validation.allowed && cached !== null && (mapping.permissionLevel === "write" || mapping.permissionLevel === "delete")) {
|
|
2513
|
+
const refreshedScopes = await fetchGrantedScopes(agentId, input.apiKey, input.baseUrl);
|
|
2514
|
+
if (refreshedScopes.length > 0) {
|
|
2515
|
+
grantedScopes = refreshedScopes;
|
|
2516
|
+
input.scopeCache.set(cacheKey, { agentId, grantedScopes });
|
|
2517
|
+
validation = validateScopeAccess(grantedScopes, requested);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
if (!validation.allowed) {
|
|
2521
|
+
if (mapping.permissionLevel === "write" || mapping.permissionLevel === "delete") {
|
|
2522
|
+
const consentUrl = buildConsentUrl(
|
|
2523
|
+
agentName,
|
|
2524
|
+
mapping.service,
|
|
2525
|
+
mapping.permissionLevel,
|
|
2526
|
+
input.dashboardUrl,
|
|
2527
|
+
input.platform,
|
|
2528
|
+
connectedServices
|
|
2529
|
+
);
|
|
2530
|
+
void recordAuditAction(
|
|
2531
|
+
{
|
|
2532
|
+
agent: agentName,
|
|
2533
|
+
service: mapping.service,
|
|
2534
|
+
actionType: mapping.actionType,
|
|
2535
|
+
status: "blocked"
|
|
2536
|
+
},
|
|
2537
|
+
input.apiKey,
|
|
2538
|
+
input.baseUrl,
|
|
2539
|
+
input.logger
|
|
2540
|
+
);
|
|
2541
|
+
return JSON.stringify(
|
|
2542
|
+
buildMutationPermissionRequiredResponse(
|
|
2543
|
+
input.rpc.id,
|
|
2544
|
+
mapping.service,
|
|
2545
|
+
mapping.permissionLevel,
|
|
2546
|
+
consentUrl
|
|
2547
|
+
)
|
|
2548
|
+
);
|
|
2549
|
+
}
|
|
2550
|
+
const permissionResult = await checkActionPermission(
|
|
2551
|
+
{
|
|
2552
|
+
agent: agentName,
|
|
2553
|
+
service: mapping.service,
|
|
2554
|
+
actionType: mapping.actionType,
|
|
2555
|
+
status: "approved"
|
|
2556
|
+
},
|
|
2557
|
+
input.apiKey,
|
|
2558
|
+
input.baseUrl
|
|
2559
|
+
);
|
|
2560
|
+
if (permissionResult.status === "approved") {
|
|
2561
|
+
const refreshedScopes = await fetchGrantedScopes(agentId, input.apiKey, input.baseUrl);
|
|
2562
|
+
if (refreshedScopes.length > 0) {
|
|
2563
|
+
input.scopeCache.set(cacheKey, { agentId, grantedScopes: refreshedScopes });
|
|
2564
|
+
}
|
|
2565
|
+
void recordAuditAction(
|
|
2566
|
+
{
|
|
2567
|
+
agent: agentName,
|
|
2568
|
+
service: mapping.service,
|
|
2569
|
+
actionType: mapping.actionType,
|
|
2570
|
+
status: "approved"
|
|
2571
|
+
},
|
|
2572
|
+
input.apiKey,
|
|
2573
|
+
input.baseUrl,
|
|
2574
|
+
input.logger
|
|
2575
|
+
);
|
|
2576
|
+
return null;
|
|
2577
|
+
}
|
|
2578
|
+
if (permissionResult.status === "pending" && permissionResult.approvalId !== void 0) {
|
|
2579
|
+
const consentUrl = buildConsentUrl(
|
|
2580
|
+
agentName,
|
|
2581
|
+
mapping.service,
|
|
2582
|
+
mapping.permissionLevel,
|
|
2583
|
+
input.dashboardUrl,
|
|
2584
|
+
input.platform,
|
|
2585
|
+
connectedServices
|
|
2586
|
+
);
|
|
2587
|
+
return JSON.stringify(
|
|
2588
|
+
buildPendingApprovalResponse(
|
|
2589
|
+
input.rpc.id,
|
|
2590
|
+
mapping.service,
|
|
2591
|
+
mapping.permissionLevel,
|
|
2592
|
+
input.dashboardUrl,
|
|
2593
|
+
agentName,
|
|
2594
|
+
consentUrl
|
|
2595
|
+
)
|
|
2596
|
+
);
|
|
2597
|
+
}
|
|
2598
|
+
void recordAuditAction(
|
|
2599
|
+
{
|
|
2600
|
+
agent: agentName,
|
|
2601
|
+
service: mapping.service,
|
|
2602
|
+
actionType: mapping.actionType,
|
|
2603
|
+
status: "blocked"
|
|
2604
|
+
},
|
|
2605
|
+
input.apiKey,
|
|
2606
|
+
input.baseUrl,
|
|
2607
|
+
input.logger
|
|
2608
|
+
);
|
|
2609
|
+
return JSON.stringify(
|
|
2610
|
+
buildBlockedResponse(
|
|
2611
|
+
input.rpc.id,
|
|
2612
|
+
mapping.service,
|
|
2613
|
+
mapping.permissionLevel,
|
|
2614
|
+
input.dashboardUrl
|
|
2615
|
+
)
|
|
2616
|
+
);
|
|
2617
|
+
}
|
|
2618
|
+
void recordAuditAction(
|
|
2619
|
+
{
|
|
2620
|
+
agent: agentName,
|
|
2621
|
+
service: mapping.service,
|
|
2622
|
+
actionType: mapping.actionType,
|
|
2623
|
+
status: "approved"
|
|
2624
|
+
},
|
|
2625
|
+
input.apiKey,
|
|
2626
|
+
input.baseUrl,
|
|
2627
|
+
input.logger
|
|
2628
|
+
);
|
|
2629
|
+
return null;
|
|
2630
|
+
} catch {
|
|
2631
|
+
return JSON.stringify(buildInternalErrorResponse(input.rpc.id));
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
async function resolveAgentAndScopes(serverName, apiKey, baseUrl, platform) {
|
|
2635
|
+
const existing = await findAgentByName(serverName, apiKey, baseUrl);
|
|
2636
|
+
if (existing?.authInvalid) {
|
|
2637
|
+
return { kind: "auth" };
|
|
2638
|
+
}
|
|
2639
|
+
let agentId;
|
|
2640
|
+
if (existing !== null) {
|
|
2641
|
+
agentId = existing.id;
|
|
2642
|
+
} else {
|
|
2643
|
+
try {
|
|
2644
|
+
agentId = await registerAgent(serverName, apiKey, baseUrl, platform);
|
|
2645
|
+
} catch (err) {
|
|
2646
|
+
if (err instanceof ShieldAuthError) {
|
|
2647
|
+
return { kind: "auth" };
|
|
2648
|
+
}
|
|
2649
|
+
return { kind: "unreachable" };
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
if (agentId.length === 0) {
|
|
2653
|
+
return { kind: "unreachable" };
|
|
2654
|
+
}
|
|
2655
|
+
const scopes = await fetchGrantedScopes(agentId, apiKey, baseUrl);
|
|
2656
|
+
return { kind: "ok", agentId, scopes };
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
// ../multicorn-proxy/src/proxy-handler.ts
|
|
2660
|
+
var MCP_PROXY_MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
2661
|
+
var UPSTREAM_TIMEOUT_MS2 = 3e4;
|
|
2662
|
+
var ALLOWED_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
2663
|
+
"content-type",
|
|
2664
|
+
"content-length",
|
|
2665
|
+
"content-encoding",
|
|
2666
|
+
"transfer-encoding",
|
|
2667
|
+
"mcp-session-id",
|
|
2668
|
+
"mcp-protocol-version",
|
|
2669
|
+
"cache-control"
|
|
2670
|
+
]);
|
|
2671
|
+
function filterUpstreamResponseHeaders(headers, logger) {
|
|
2672
|
+
const out = {};
|
|
2673
|
+
headers.forEach((value, key) => {
|
|
2674
|
+
const nameLower = key.toLowerCase();
|
|
2675
|
+
if (ALLOWED_RESPONSE_HEADERS.has(nameLower)) {
|
|
2676
|
+
out[nameLower] = value;
|
|
2677
|
+
} else {
|
|
2678
|
+
logger.debug("Dropped upstream response header.", {
|
|
2679
|
+
action: "drop_upstream_header",
|
|
2680
|
+
key: nameLower
|
|
2681
|
+
});
|
|
2682
|
+
}
|
|
2683
|
+
});
|
|
2684
|
+
return out;
|
|
2685
|
+
}
|
|
2686
|
+
function headersToRecord(headers) {
|
|
2687
|
+
const out = {};
|
|
2688
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
2689
|
+
out[k] = Array.isArray(v) ? v[0] : v;
|
|
2690
|
+
}
|
|
2691
|
+
return out;
|
|
2692
|
+
}
|
|
2693
|
+
async function pipeWebStreamToNode(body, res) {
|
|
2694
|
+
if (body === null) {
|
|
2695
|
+
res.end();
|
|
2696
|
+
return;
|
|
2697
|
+
}
|
|
2698
|
+
const reader = body.getReader();
|
|
2699
|
+
try {
|
|
2700
|
+
for (; ; ) {
|
|
2701
|
+
const { done, value } = await reader.read();
|
|
2702
|
+
if (done) break;
|
|
2703
|
+
if (value !== void 0 && value.byteLength > 0) {
|
|
2704
|
+
res.write(Buffer.from(value));
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
} finally {
|
|
2708
|
+
reader.releaseLock();
|
|
2709
|
+
res.end();
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
async function readResponseText(body) {
|
|
2713
|
+
if (body === null) return "";
|
|
2714
|
+
const reader = body.getReader();
|
|
2715
|
+
const chunks = [];
|
|
2716
|
+
try {
|
|
2717
|
+
for (; ; ) {
|
|
2718
|
+
const { done, value } = await reader.read();
|
|
2719
|
+
if (done) break;
|
|
2720
|
+
if (value !== void 0) chunks.push(value);
|
|
2721
|
+
}
|
|
2722
|
+
} finally {
|
|
2723
|
+
reader.releaseLock();
|
|
2724
|
+
}
|
|
2725
|
+
return new TextDecoder().decode(Buffer.concat(chunks));
|
|
2726
|
+
}
|
|
2727
|
+
async function pipeSseUpstreamToBridge(body, sessionKey, bridgeStore) {
|
|
2728
|
+
const reader = body.getReader();
|
|
2729
|
+
const decoder = new TextDecoder();
|
|
2730
|
+
let buffer = "";
|
|
2731
|
+
try {
|
|
2732
|
+
for (; ; ) {
|
|
2733
|
+
const { done, value } = await reader.read();
|
|
2734
|
+
if (done) break;
|
|
2735
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2736
|
+
let boundary = buffer.indexOf("\n\n");
|
|
2737
|
+
while (boundary !== -1) {
|
|
2738
|
+
const block = buffer.slice(0, boundary);
|
|
2739
|
+
buffer = buffer.slice(boundary + 2);
|
|
2740
|
+
const dataLines = [];
|
|
2741
|
+
for (const line of block.split("\n")) {
|
|
2742
|
+
if (line.startsWith("data: ")) {
|
|
2743
|
+
dataLines.push(line.slice(6));
|
|
2744
|
+
} else if (line.startsWith("data:")) {
|
|
2745
|
+
dataLines.push(line.slice(5));
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
if (dataLines.length > 0) {
|
|
2749
|
+
const payload = dataLines.join("\n");
|
|
2750
|
+
bridgeStore.sendEvent(sessionKey, payload);
|
|
2751
|
+
}
|
|
2752
|
+
boundary = buffer.indexOf("\n\n");
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
if (buffer.trim().length > 0) {
|
|
2756
|
+
const dataLines = [];
|
|
2757
|
+
for (const line of buffer.split("\n")) {
|
|
2758
|
+
if (line.startsWith("data: ")) {
|
|
2759
|
+
dataLines.push(line.slice(6));
|
|
2760
|
+
} else if (line.startsWith("data:")) {
|
|
2761
|
+
dataLines.push(line.slice(5));
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
if (dataLines.length > 0) {
|
|
2765
|
+
bridgeStore.sendEvent(sessionKey, dataLines.join("\n"));
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
} finally {
|
|
2769
|
+
reader.releaseLock();
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
function createProxyRequestHandler(deps) {
|
|
2773
|
+
const { env, configResolver, scopeCache, rateLimiter, logger, bridgeStore } = deps;
|
|
2774
|
+
return async function handleProxiedRequest(req, res, route, apiKey) {
|
|
2775
|
+
const keyHash = configResolver.hashKey(apiKey);
|
|
2776
|
+
const retryAfter = rateLimiter.check(keyHash);
|
|
2777
|
+
if (retryAfter !== null) {
|
|
2778
|
+
res.writeHead(429, {
|
|
2779
|
+
"Retry-After": String(retryAfter),
|
|
2780
|
+
"Content-Type": "application/json"
|
|
2781
|
+
});
|
|
2782
|
+
res.end(
|
|
2783
|
+
JSON.stringify({ type: "about:blank", title: "Too Many Requests", status: 429 })
|
|
2784
|
+
);
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
let resolveBody;
|
|
2788
|
+
try {
|
|
2789
|
+
resolveBody = await configResolver.resolve(route.routingToken, apiKey);
|
|
2790
|
+
} catch (e) {
|
|
2791
|
+
if (e instanceof TargetUrlError) {
|
|
2792
|
+
logger.warn("Rejected target URL from config resolve.", { error: e.message });
|
|
2793
|
+
res.writeHead(502, { "Content-Type": "application/problem+json" });
|
|
2794
|
+
res.end(
|
|
2795
|
+
JSON.stringify({
|
|
2796
|
+
type: "https://multicorn.ai/errors/bad-gateway",
|
|
2797
|
+
title: "Bad Gateway",
|
|
2798
|
+
status: 502,
|
|
2799
|
+
detail: e.message
|
|
2800
|
+
})
|
|
2801
|
+
);
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
if (e instanceof ResolveError) {
|
|
2805
|
+
if (e.code === "unauthorized") {
|
|
2806
|
+
res.writeHead(401, { "Content-Type": "application/problem+json" });
|
|
2807
|
+
res.end(
|
|
2808
|
+
JSON.stringify({
|
|
2809
|
+
type: "https://multicorn.ai/errors/unauthorized",
|
|
2810
|
+
title: "Unauthorized",
|
|
2811
|
+
status: 401,
|
|
2812
|
+
detail: e.message
|
|
2813
|
+
})
|
|
2814
|
+
);
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
if (e.code === "forbidden") {
|
|
2818
|
+
res.writeHead(403, { "Content-Type": "application/problem+json" });
|
|
2819
|
+
res.end(
|
|
2820
|
+
JSON.stringify({
|
|
2821
|
+
type: "https://multicorn.ai/errors/forbidden",
|
|
2822
|
+
title: "Forbidden",
|
|
2823
|
+
status: 403,
|
|
2824
|
+
detail: e.message
|
|
2825
|
+
})
|
|
2826
|
+
);
|
|
2827
|
+
return;
|
|
2828
|
+
}
|
|
2829
|
+
if (e.code === "not_found") {
|
|
2830
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2831
|
+
res.end(JSON.stringify({ error: "unknown_routing_token" }));
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
logger.warn("Config resolve failed.", { error: e instanceof Error ? e.message : String(e) });
|
|
2836
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
2837
|
+
res.end(JSON.stringify({ error: "bad_gateway" }));
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2840
|
+
logger.info("Config resolve completed.", {
|
|
2841
|
+
routingToken: route.routingToken,
|
|
2842
|
+
targetUrl: resolveBody.targetUrl,
|
|
2843
|
+
hasServiceTokens: resolveBody.serviceTokens !== void 0,
|
|
2844
|
+
hasGoogleServiceToken: resolveBody.serviceTokens?.google !== void 0,
|
|
2845
|
+
// Scope identifiers are not secrets; logged to diagnose consent URL scopes.
|
|
2846
|
+
googleGrantedScopes: resolveBody.serviceTokens?.google?.grantedScopes ?? "(none)"
|
|
2847
|
+
});
|
|
2848
|
+
const dashboardUrl = deriveDashboardUrl(env.shieldApiBaseUrl);
|
|
2849
|
+
const headerRecord = headersToRecord(req.headers);
|
|
2850
|
+
const agentNameChanged = configResolver.trackAgentName(route.routingToken, resolveBody.agentName);
|
|
2851
|
+
if (agentNameChanged) {
|
|
2852
|
+
const scopeCacheKey = `${route.routingToken}|${keyHash}`;
|
|
2853
|
+
scopeCache.invalidate(scopeCacheKey);
|
|
2854
|
+
}
|
|
2855
|
+
const aggUpstreams = resolveBody.upstreams;
|
|
2856
|
+
if (aggUpstreams !== void 0 && aggUpstreams.length > 1) {
|
|
2857
|
+
const specs = aggUpstreams.filter(
|
|
2858
|
+
(u) => u.kind === "builtin" || u.kind === "hosted" || u.kind === "local" || u.kind === "http"
|
|
2859
|
+
).map((u) => ({ url: u.targetUrl, kind: u.kind }));
|
|
2860
|
+
if (req.method === "GET") {
|
|
2861
|
+
const sessionKey2 = `${route.routingToken}|${keyHash}`;
|
|
2862
|
+
const endpointWithKey = `${route.pathPrefix}?key=${encodeURIComponent(apiKey)}`;
|
|
2863
|
+
bridgeStore.open(sessionKey2, res, endpointWithKey);
|
|
2864
|
+
req.on("close", () => bridgeStore.remove(sessionKey2));
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
let aggBody;
|
|
2868
|
+
try {
|
|
2869
|
+
aggBody = await bufferFromRequest(req, MCP_PROXY_MAX_BODY_BYTES);
|
|
2870
|
+
} catch {
|
|
2871
|
+
res.writeHead(413).end();
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2874
|
+
const aggCt = headerRecord["content-type"] ?? "";
|
|
2875
|
+
if (req.method === "POST" && aggCt.includes("application/json") && aggBody.length > 0) {
|
|
2876
|
+
const rpc = parseJsonRpcLine(aggBody.toString("utf8"));
|
|
2877
|
+
if (rpc !== null) {
|
|
2878
|
+
const ac2 = new AbortController();
|
|
2879
|
+
const timer2 = setTimeout(() => ac2.abort(), AGGREGATOR_UPSTREAM_TIMEOUT_MS);
|
|
2880
|
+
let aggregated;
|
|
2881
|
+
try {
|
|
2882
|
+
aggregated = await handleAggregatedRequest({
|
|
2883
|
+
rpc,
|
|
2884
|
+
upstreams: specs,
|
|
2885
|
+
apiKey,
|
|
2886
|
+
routingToken: route.routingToken,
|
|
2887
|
+
keyHash,
|
|
2888
|
+
signal: ac2.signal,
|
|
2889
|
+
logger,
|
|
2890
|
+
interceptLocal: (innerRpc) => maybeInterceptToolsCall({
|
|
2891
|
+
rpc: innerRpc,
|
|
2892
|
+
apiKey,
|
|
2893
|
+
baseUrl: env.shieldApiBaseUrl,
|
|
2894
|
+
dashboardUrl,
|
|
2895
|
+
serverName: resolveBody.serverName,
|
|
2896
|
+
routingToken: route.routingToken,
|
|
2897
|
+
keyHash,
|
|
2898
|
+
scopeCache,
|
|
2899
|
+
platform: resolveBody.platform ?? "other-mcp",
|
|
2900
|
+
agentName: resolveBody.agentName,
|
|
2901
|
+
googleGrantedScopes: resolveBody.serviceTokens?.google?.grantedScopes,
|
|
2902
|
+
logger
|
|
2903
|
+
}),
|
|
2904
|
+
// Serves the in-process built-in MCP (gmail/calendar/drive) with this proxy's
|
|
2905
|
+
// service tokens. Present only when the backend kept the built-in in-process
|
|
2906
|
+
// (i.e. this proxy holds the resolve secret); otherwise the built-in arrives as a
|
|
2907
|
+
// "hosted" upstream and is forwarded to the cloud instead.
|
|
2908
|
+
serveBuiltin: (innerRpc) => handleMulticornMcpRequest(innerRpc, {
|
|
2909
|
+
google: resolveBody.serviceTokens?.google,
|
|
2910
|
+
workspace: {
|
|
2911
|
+
apiKey,
|
|
2912
|
+
baseUrl: env.shieldApiBaseUrl,
|
|
2913
|
+
userId: resolveBody.userId
|
|
2914
|
+
}
|
|
2915
|
+
})
|
|
2916
|
+
});
|
|
2917
|
+
} catch (err) {
|
|
2918
|
+
logger.warn("Aggregated request failed.", {
|
|
2919
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2920
|
+
});
|
|
2921
|
+
aggregated = JSON.stringify({
|
|
2922
|
+
jsonrpc: "2.0",
|
|
2923
|
+
id: rpc.id ?? null,
|
|
2924
|
+
error: { code: -32603, message: "Aggregation failed" }
|
|
2925
|
+
});
|
|
2926
|
+
} finally {
|
|
2927
|
+
clearTimeout(timer2);
|
|
2928
|
+
}
|
|
2929
|
+
const sessionKey2 = `${route.routingToken}|${keyHash}`;
|
|
2930
|
+
const bridgeSession2 = bridgeStore.get(sessionKey2);
|
|
2931
|
+
if (aggregated === null) {
|
|
2932
|
+
res.writeHead(bridgeSession2 ? 202 : 204).end();
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
if (bridgeSession2) {
|
|
2936
|
+
bridgeStore.sendEvent(sessionKey2, aggregated);
|
|
2937
|
+
res.writeHead(202).end();
|
|
2938
|
+
} else {
|
|
2939
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2940
|
+
res.end(aggregated);
|
|
2941
|
+
}
|
|
2942
|
+
return;
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2946
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
if (resolveBody.targetUrl === MULTICORN_MCP_SENTINEL2) {
|
|
2950
|
+
if (req.method === "GET") {
|
|
2951
|
+
const sessionKey2 = `${route.routingToken}|${keyHash}`;
|
|
2952
|
+
const endpointWithKey = `${route.pathPrefix}?key=${encodeURIComponent(apiKey)}`;
|
|
2953
|
+
bridgeStore.open(sessionKey2, res, endpointWithKey);
|
|
2954
|
+
req.on("close", () => bridgeStore.remove(sessionKey2));
|
|
2955
|
+
return;
|
|
2956
|
+
}
|
|
2957
|
+
let body2;
|
|
2958
|
+
try {
|
|
2959
|
+
body2 = await bufferFromRequest(req, MCP_PROXY_MAX_BODY_BYTES);
|
|
2960
|
+
} catch {
|
|
2961
|
+
res.writeHead(413).end();
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
const ct2 = headerRecord["content-type"] ?? "";
|
|
2965
|
+
if (req.method === "POST" && ct2.includes("application/json") && body2.length > 0) {
|
|
2966
|
+
const line = body2.toString("utf8");
|
|
2967
|
+
const rpc = parseJsonRpcLine(line);
|
|
2968
|
+
if (rpc !== null) {
|
|
2969
|
+
const override = await maybeInterceptToolsCall({
|
|
2970
|
+
rpc,
|
|
2971
|
+
apiKey,
|
|
2972
|
+
baseUrl: env.shieldApiBaseUrl,
|
|
2973
|
+
dashboardUrl,
|
|
2974
|
+
serverName: resolveBody.serverName,
|
|
2975
|
+
routingToken: route.routingToken,
|
|
2976
|
+
keyHash,
|
|
2977
|
+
scopeCache,
|
|
2978
|
+
platform: resolveBody.platform ?? "other-mcp",
|
|
2979
|
+
agentName: resolveBody.agentName,
|
|
2980
|
+
googleGrantedScopes: resolveBody.serviceTokens?.google?.grantedScopes,
|
|
2981
|
+
logger
|
|
2982
|
+
});
|
|
2983
|
+
if (override !== null) {
|
|
2984
|
+
const sessionKey2 = `${route.routingToken}|${keyHash}`;
|
|
2985
|
+
const bridgeSession2 = bridgeStore.get(sessionKey2);
|
|
2986
|
+
if (bridgeSession2) {
|
|
2987
|
+
bridgeStore.sendEvent(sessionKey2, override);
|
|
2988
|
+
res.writeHead(202).end();
|
|
2989
|
+
} else {
|
|
2990
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2991
|
+
res.end(override);
|
|
2992
|
+
}
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
const mcpResponse = await handleMulticornMcpRequest(rpc, {
|
|
2996
|
+
google: resolveBody.serviceTokens?.google,
|
|
2997
|
+
workspace: {
|
|
2998
|
+
apiKey,
|
|
2999
|
+
baseUrl: env.shieldApiBaseUrl,
|
|
3000
|
+
userId: resolveBody.userId
|
|
3001
|
+
}
|
|
3002
|
+
});
|
|
3003
|
+
if (mcpResponse !== null) {
|
|
3004
|
+
const sessionKey2 = `${route.routingToken}|${keyHash}`;
|
|
3005
|
+
const bridgeSession2 = bridgeStore.get(sessionKey2);
|
|
3006
|
+
if (bridgeSession2) {
|
|
3007
|
+
bridgeStore.sendEvent(sessionKey2, mcpResponse);
|
|
3008
|
+
res.writeHead(202).end();
|
|
3009
|
+
} else {
|
|
3010
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3011
|
+
res.end(mcpResponse);
|
|
3012
|
+
}
|
|
3013
|
+
return;
|
|
3014
|
+
}
|
|
3015
|
+
res.writeHead(204).end();
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3020
|
+
res.end(JSON.stringify({ error: "invalid_request" }));
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
let forwardUrl;
|
|
3024
|
+
try {
|
|
3025
|
+
forwardUrl = stripKeyQueryParamFromUrl(buildForwardUrl(resolveBody.targetUrl, route.restPath));
|
|
3026
|
+
} catch (e) {
|
|
3027
|
+
if (e instanceof PathTraversalError) {
|
|
3028
|
+
logger.warn("Rejected path traversal in proxy path.", {
|
|
3029
|
+
restPath: route.restPath,
|
|
3030
|
+
error: e.message
|
|
3031
|
+
});
|
|
3032
|
+
res.writeHead(400, { "Content-Type": "application/problem+json" });
|
|
3033
|
+
res.end(
|
|
3034
|
+
JSON.stringify({
|
|
3035
|
+
type: "https://multicorn.ai/errors/bad-request",
|
|
3036
|
+
title: "Bad Request",
|
|
3037
|
+
status: 400,
|
|
3038
|
+
detail: "Invalid path segment"
|
|
3039
|
+
})
|
|
3040
|
+
);
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
throw e;
|
|
3044
|
+
}
|
|
3045
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
3046
|
+
const ac2 = new AbortController();
|
|
3047
|
+
const timer2 = setTimeout(() => ac2.abort(), UPSTREAM_TIMEOUT_MS2);
|
|
3048
|
+
try {
|
|
3049
|
+
const upstream = await forwardToMcp(forwardUrl, {
|
|
3050
|
+
method: req.method,
|
|
3051
|
+
headers: headerRecord,
|
|
3052
|
+
body: void 0,
|
|
3053
|
+
signal: ac2.signal
|
|
3054
|
+
}, { configUpstreamHeaders: resolveBody.upstreamHeaders });
|
|
3055
|
+
if (upstream.status === 405) {
|
|
3056
|
+
if (upstream.body) {
|
|
3057
|
+
await upstream.body.cancel();
|
|
3058
|
+
}
|
|
3059
|
+
logger.debug("Upstream returned 405 on GET; opening SSE bridge.", { forwardUrl });
|
|
3060
|
+
const sessionKey2 = `${route.routingToken}|${keyHash}`;
|
|
3061
|
+
const endpointWithKey = `${route.pathPrefix}?key=${encodeURIComponent(apiKey)}`;
|
|
3062
|
+
bridgeStore.open(sessionKey2, res, endpointWithKey);
|
|
3063
|
+
req.on("close", () => bridgeStore.remove(sessionKey2));
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
const upstreamCt = (upstream.headers.get("content-type") ?? "").toLowerCase();
|
|
3067
|
+
const isValidSseResponse = upstream.status === 200 && upstreamCt.includes("text/event-stream");
|
|
3068
|
+
if (!isValidSseResponse) {
|
|
3069
|
+
if (upstream.body) {
|
|
3070
|
+
await upstream.body.cancel();
|
|
3071
|
+
}
|
|
3072
|
+
logger.debug("Upstream did not return SSE on GET; opening SSE bridge.", {
|
|
3073
|
+
forwardUrl,
|
|
3074
|
+
upstreamStatus: upstream.status,
|
|
3075
|
+
upstreamContentType: upstreamCt
|
|
3076
|
+
});
|
|
3077
|
+
const sessionKey2 = `${route.routingToken}|${keyHash}`;
|
|
3078
|
+
const endpointWithKey = `${route.pathPrefix}?key=${encodeURIComponent(apiKey)}`;
|
|
3079
|
+
bridgeStore.open(sessionKey2, res, endpointWithKey);
|
|
3080
|
+
req.on("close", () => bridgeStore.remove(sessionKey2));
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
const upstreamHeaders = filterUpstreamResponseHeaders(upstream.headers, logger);
|
|
3084
|
+
res.writeHead(upstream.status, upstreamHeaders);
|
|
3085
|
+
if (req.method === "HEAD") {
|
|
3086
|
+
res.end();
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
await pipeWebStreamToNode(upstream.body, res);
|
|
3090
|
+
} catch (err) {
|
|
3091
|
+
logger.warn("Upstream forward failed (GET).", {
|
|
3092
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3093
|
+
});
|
|
3094
|
+
if (!res.headersSent) {
|
|
3095
|
+
res.writeHead(502).end();
|
|
3096
|
+
}
|
|
3097
|
+
} finally {
|
|
3098
|
+
clearTimeout(timer2);
|
|
3099
|
+
}
|
|
3100
|
+
return;
|
|
3101
|
+
}
|
|
3102
|
+
let body;
|
|
3103
|
+
try {
|
|
3104
|
+
body = await bufferFromRequest(req, MCP_PROXY_MAX_BODY_BYTES);
|
|
3105
|
+
} catch {
|
|
3106
|
+
res.writeHead(413).end();
|
|
3107
|
+
return;
|
|
3108
|
+
}
|
|
3109
|
+
const ct = headerRecord["content-type"] ?? "";
|
|
3110
|
+
if (req.method === "POST" && ct.includes("application/json") && body.length > 0) {
|
|
3111
|
+
const line = body.toString("utf8");
|
|
3112
|
+
const rpc = parseJsonRpcLine(line);
|
|
3113
|
+
if (rpc !== null) {
|
|
3114
|
+
const override = await maybeInterceptToolsCall({
|
|
3115
|
+
rpc,
|
|
3116
|
+
apiKey,
|
|
3117
|
+
baseUrl: env.shieldApiBaseUrl,
|
|
3118
|
+
dashboardUrl,
|
|
3119
|
+
serverName: resolveBody.serverName,
|
|
3120
|
+
routingToken: route.routingToken,
|
|
3121
|
+
keyHash,
|
|
3122
|
+
scopeCache,
|
|
3123
|
+
platform: resolveBody.platform ?? "other-mcp",
|
|
3124
|
+
agentName: resolveBody.agentName,
|
|
3125
|
+
googleGrantedScopes: resolveBody.serviceTokens?.google?.grantedScopes,
|
|
3126
|
+
// Pass the logger so action recording on the forwarded (e.g. local filesystem)
|
|
3127
|
+
// path is observable. Without it, a dropped or blocked audit write here was
|
|
3128
|
+
// completely silent - the exact blind spot for local-files agents.
|
|
3129
|
+
logger
|
|
3130
|
+
});
|
|
3131
|
+
if (override !== null) {
|
|
3132
|
+
const sessionKey2 = `${route.routingToken}|${keyHash}`;
|
|
3133
|
+
const bridgeSession2 = bridgeStore.get(sessionKey2);
|
|
3134
|
+
if (bridgeSession2) {
|
|
3135
|
+
bridgeStore.sendEvent(sessionKey2, override);
|
|
3136
|
+
res.writeHead(202).end();
|
|
3137
|
+
} else {
|
|
3138
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3139
|
+
res.end(override);
|
|
3140
|
+
}
|
|
3141
|
+
return;
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
const sessionKey = `${route.routingToken}|${keyHash}`;
|
|
3146
|
+
const bridgeSession = bridgeStore.get(sessionKey);
|
|
3147
|
+
const ac = new AbortController();
|
|
3148
|
+
const timer = setTimeout(() => ac.abort(), UPSTREAM_TIMEOUT_MS2);
|
|
3149
|
+
try {
|
|
3150
|
+
const upstream = await forwardToMcp(forwardUrl, {
|
|
3151
|
+
method: req.method ?? "POST",
|
|
3152
|
+
headers: headerRecord,
|
|
3153
|
+
body: body.length > 0 ? body : void 0,
|
|
3154
|
+
signal: ac.signal
|
|
3155
|
+
}, { configUpstreamHeaders: resolveBody.upstreamHeaders });
|
|
3156
|
+
if (bridgeSession) {
|
|
3157
|
+
const upstreamCt = upstream.headers.get("content-type") ?? "";
|
|
3158
|
+
if (upstreamCt.includes("text/event-stream") && upstream.body) {
|
|
3159
|
+
await pipeSseUpstreamToBridge(upstream.body, sessionKey, bridgeStore);
|
|
3160
|
+
} else {
|
|
3161
|
+
const text = await readResponseText(upstream.body);
|
|
3162
|
+
if (text.length > 0) {
|
|
3163
|
+
bridgeStore.sendEvent(sessionKey, text);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
res.writeHead(202).end();
|
|
3167
|
+
} else {
|
|
3168
|
+
const upstreamHeaders = filterUpstreamResponseHeaders(upstream.headers, logger);
|
|
3169
|
+
res.writeHead(upstream.status, upstreamHeaders);
|
|
3170
|
+
await pipeWebStreamToNode(upstream.body, res);
|
|
3171
|
+
}
|
|
3172
|
+
} catch (err) {
|
|
3173
|
+
logger.warn("Upstream forward failed.", {
|
|
3174
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3175
|
+
});
|
|
3176
|
+
if (bridgeSession) {
|
|
3177
|
+
bridgeStore.sendError(sessionKey, "Upstream request failed");
|
|
3178
|
+
res.writeHead(502).end();
|
|
3179
|
+
} else if (!res.headersSent) {
|
|
3180
|
+
res.writeHead(502).end();
|
|
3181
|
+
}
|
|
3182
|
+
} finally {
|
|
3183
|
+
clearTimeout(timer);
|
|
3184
|
+
}
|
|
3185
|
+
};
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
// ../multicorn-proxy/src/rate-limiter.ts
|
|
3189
|
+
function createRateLimiter(requestsPerMinute) {
|
|
3190
|
+
const windowMs = 6e4;
|
|
3191
|
+
const max = Math.max(1, requestsPerMinute);
|
|
3192
|
+
const windows = /* @__PURE__ */ new Map();
|
|
3193
|
+
function prune() {
|
|
3194
|
+
const now = Date.now();
|
|
3195
|
+
for (const [k, w] of windows) {
|
|
3196
|
+
if (w.resetAt <= now) windows.delete(k);
|
|
3197
|
+
}
|
|
3198
|
+
}
|
|
3199
|
+
return {
|
|
3200
|
+
/** @returns retryAfterSeconds when limited, otherwise null */
|
|
3201
|
+
check(key) {
|
|
3202
|
+
prune();
|
|
3203
|
+
const now = Date.now();
|
|
3204
|
+
let w = windows.get(key);
|
|
3205
|
+
if (w === void 0 || w.resetAt <= now) {
|
|
3206
|
+
w = { resetAt: now + windowMs, count: 0 };
|
|
3207
|
+
windows.set(key, w);
|
|
3208
|
+
}
|
|
3209
|
+
if (w.count >= max) {
|
|
3210
|
+
return Math.max(1, Math.ceil((w.resetAt - now) / 1e3));
|
|
3211
|
+
}
|
|
3212
|
+
w.count += 1;
|
|
3213
|
+
return null;
|
|
3214
|
+
}
|
|
3215
|
+
};
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
// ../multicorn-proxy/src/scope-cache.ts
|
|
3219
|
+
function createScopeCache(ttlMs) {
|
|
3220
|
+
const store2 = /* @__PURE__ */ new Map();
|
|
3221
|
+
function prune() {
|
|
3222
|
+
const now = Date.now();
|
|
3223
|
+
for (const [k, b] of store2) {
|
|
3224
|
+
if (b.expiresAt <= now) store2.delete(k);
|
|
3225
|
+
}
|
|
3226
|
+
}
|
|
3227
|
+
return {
|
|
3228
|
+
get(key) {
|
|
3229
|
+
prune();
|
|
3230
|
+
const b = store2.get(key);
|
|
3231
|
+
if (b === void 0 || b.expiresAt <= Date.now()) {
|
|
3232
|
+
if (b !== void 0) store2.delete(key);
|
|
3233
|
+
return null;
|
|
3234
|
+
}
|
|
3235
|
+
return b.entry;
|
|
3236
|
+
},
|
|
3237
|
+
set(key, entry) {
|
|
3238
|
+
store2.set(key, { entry, expiresAt: Date.now() + ttlMs });
|
|
3239
|
+
},
|
|
3240
|
+
invalidate(key) {
|
|
3241
|
+
store2.delete(key);
|
|
3242
|
+
}
|
|
3243
|
+
};
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
// ../multicorn-proxy/src/sse-bridge.ts
|
|
3247
|
+
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
3248
|
+
function createSseBridgeStore(logger) {
|
|
3249
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
3250
|
+
function closeSession(key, session) {
|
|
3251
|
+
clearInterval(session.heartbeatTimer);
|
|
3252
|
+
if (!session.res.writableEnded) {
|
|
3253
|
+
session.res.end();
|
|
3254
|
+
}
|
|
3255
|
+
sessions.delete(key);
|
|
3256
|
+
logger.debug("SSE bridge session closed.", { key });
|
|
3257
|
+
}
|
|
3258
|
+
return {
|
|
3259
|
+
/**
|
|
3260
|
+
* Open a new SSE stream for the given session key.
|
|
3261
|
+
* If a session already exists for this key, the old one is closed first.
|
|
3262
|
+
* Writes the SSE headers and the `event: endpoint` message.
|
|
3263
|
+
*/
|
|
3264
|
+
open(key, res, endpointPath) {
|
|
3265
|
+
const existing = sessions.get(key);
|
|
3266
|
+
if (existing) {
|
|
3267
|
+
closeSession(key, existing);
|
|
3268
|
+
}
|
|
3269
|
+
res.writeHead(200, {
|
|
3270
|
+
"Content-Type": "text/event-stream",
|
|
3271
|
+
"Cache-Control": "no-cache",
|
|
3272
|
+
"Connection": "keep-alive"
|
|
3273
|
+
});
|
|
3274
|
+
res.write(`event: endpoint
|
|
3275
|
+
data: ${endpointPath}
|
|
3276
|
+
|
|
3277
|
+
`);
|
|
3278
|
+
const heartbeatTimer = setInterval(() => {
|
|
3279
|
+
if (!res.writableEnded) {
|
|
3280
|
+
res.write(":\n\n");
|
|
3281
|
+
}
|
|
3282
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
3283
|
+
const session = { res, heartbeatTimer, createdAt: Date.now() };
|
|
3284
|
+
sessions.set(key, session);
|
|
3285
|
+
logger.debug("SSE bridge session opened.", { key, endpointPath });
|
|
3286
|
+
},
|
|
3287
|
+
/** Get the active session for a key, or undefined if none exists. */
|
|
3288
|
+
get(key) {
|
|
3289
|
+
return sessions.get(key);
|
|
3290
|
+
},
|
|
3291
|
+
/** Remove and close a session. */
|
|
3292
|
+
remove(key) {
|
|
3293
|
+
const session = sessions.get(key);
|
|
3294
|
+
if (session) {
|
|
3295
|
+
closeSession(key, session);
|
|
3296
|
+
}
|
|
3297
|
+
},
|
|
3298
|
+
/**
|
|
3299
|
+
* Send one SSE `message` event on the session's GET stream.
|
|
3300
|
+
* Returns false if the session doesn't exist or the stream is closed.
|
|
3301
|
+
*/
|
|
3302
|
+
sendEvent(key, data) {
|
|
3303
|
+
const session = sessions.get(key);
|
|
3304
|
+
if (!session || session.res.writableEnded) {
|
|
3305
|
+
return false;
|
|
3306
|
+
}
|
|
3307
|
+
const lines = data.split("\n").map((line) => `data: ${line}`).join("\n");
|
|
3308
|
+
session.res.write(`event: message
|
|
3309
|
+
${lines}
|
|
3310
|
+
|
|
3311
|
+
`);
|
|
3312
|
+
return true;
|
|
3313
|
+
},
|
|
3314
|
+
/**
|
|
3315
|
+
* Send an SSE error event, then close the session.
|
|
3316
|
+
*/
|
|
3317
|
+
sendError(key, message) {
|
|
3318
|
+
const session = sessions.get(key);
|
|
3319
|
+
if (!session || session.res.writableEnded) {
|
|
3320
|
+
return;
|
|
3321
|
+
}
|
|
3322
|
+
session.res.write(`event: error
|
|
3323
|
+
data: ${JSON.stringify({ error: message })}
|
|
3324
|
+
|
|
3325
|
+
`);
|
|
3326
|
+
closeSession(key, session);
|
|
3327
|
+
},
|
|
3328
|
+
/** Number of active sessions (for metrics/debugging). */
|
|
3329
|
+
size() {
|
|
3330
|
+
return sessions.size;
|
|
3331
|
+
}
|
|
3332
|
+
};
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
// ../multicorn-proxy/src/server.ts
|
|
3336
|
+
var ROUTE_RE = /^\/r\/([^/]+)\/([^/]+)(.*)$/;
|
|
3337
|
+
function matchRoute(pathname) {
|
|
3338
|
+
const m = ROUTE_RE.exec(pathname);
|
|
3339
|
+
if (m === null) return null;
|
|
3340
|
+
const routingToken = m[1] ?? "";
|
|
3341
|
+
const pathSegment = m[2] ?? "";
|
|
3342
|
+
const restPath = m[3] ?? "";
|
|
3343
|
+
if (routingToken.length === 0 || pathSegment.length === 0) return null;
|
|
3344
|
+
const pathPrefix = `/r/${routingToken}/${pathSegment}`;
|
|
3345
|
+
return { routingToken, pathSegment, pathPrefix, restPath };
|
|
3346
|
+
}
|
|
3347
|
+
function headersForDebugLog(headers) {
|
|
3348
|
+
const out = {};
|
|
3349
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
3350
|
+
const val = Array.isArray(v) ? v.join(", ") : v ?? "";
|
|
3351
|
+
const keyLower = k.toLowerCase();
|
|
3352
|
+
if (keyLower === "authorization" || keyLower === "x-multicorn-key") {
|
|
3353
|
+
out[k] = val.length > 0 ? `[present:${String(val.length)} chars]` : "";
|
|
3354
|
+
} else {
|
|
3355
|
+
out[k] = val;
|
|
3356
|
+
}
|
|
3357
|
+
}
|
|
3358
|
+
return out;
|
|
3359
|
+
}
|
|
3360
|
+
function applyProxyServerTimeouts(server, env) {
|
|
3361
|
+
server.requestTimeout = env.serverRequestTimeoutMs;
|
|
3362
|
+
server.headersTimeout = env.serverHeadersTimeoutMs;
|
|
3363
|
+
server.keepAliveTimeout = 65e3;
|
|
3364
|
+
}
|
|
3365
|
+
function main() {
|
|
3366
|
+
const env = loadEnv();
|
|
3367
|
+
const logger = createLogger(env.logLevel);
|
|
3368
|
+
const configResolver = createConfigResolver(
|
|
3369
|
+
env.shieldApiBaseUrl,
|
|
3370
|
+
env.configResolveTtlMs,
|
|
3371
|
+
env.allowPrivateTargets,
|
|
3372
|
+
env.proxyResolveInternalSecret,
|
|
3373
|
+
env.configResolveMcpTtlMs
|
|
3374
|
+
);
|
|
3375
|
+
const scopeCache = createScopeCache(env.scopeCacheTtlMs);
|
|
3376
|
+
const rateLimiter = createRateLimiter(env.rateLimitRpm);
|
|
3377
|
+
const bridgeStore = createSseBridgeStore(logger);
|
|
3378
|
+
const handleProxied = createProxyRequestHandler({
|
|
3379
|
+
env,
|
|
3380
|
+
configResolver,
|
|
3381
|
+
scopeCache,
|
|
3382
|
+
rateLimiter,
|
|
3383
|
+
logger,
|
|
3384
|
+
bridgeStore
|
|
3385
|
+
});
|
|
3386
|
+
const server = createServer((req, res) => {
|
|
3387
|
+
req.setTimeout(env.serverRequestTimeoutMs, () => {
|
|
3388
|
+
req.destroy();
|
|
3389
|
+
});
|
|
3390
|
+
void (async () => {
|
|
3391
|
+
try {
|
|
3392
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
3393
|
+
const redactedUrl = redactKeyQueryParamForLogs(req.url ?? "");
|
|
3394
|
+
logger.info("[DEBUG] Incoming request", {
|
|
3395
|
+
method: req.method,
|
|
3396
|
+
url: redactedUrl,
|
|
3397
|
+
hasKeyParam: url.searchParams.has("key"),
|
|
3398
|
+
headers: headersForDebugLog(req.headers)
|
|
3399
|
+
});
|
|
3400
|
+
if (req.method === "GET" && url.pathname === "/health") {
|
|
3401
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3402
|
+
res.end(JSON.stringify({ status: "ok", version: PROXY_VERSION }));
|
|
3403
|
+
return;
|
|
3404
|
+
}
|
|
3405
|
+
const route = matchRoute(url.pathname);
|
|
3406
|
+
if (route === null) {
|
|
3407
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
3408
|
+
res.end(JSON.stringify({ error: "not_found" }));
|
|
3409
|
+
return;
|
|
3410
|
+
}
|
|
3411
|
+
if (route.restPath.toLowerCase().includes("/.well-known/")) {
|
|
3412
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
3413
|
+
res.end(JSON.stringify({ error: "not_found" }));
|
|
3414
|
+
return;
|
|
3415
|
+
}
|
|
3416
|
+
logger.debug("Proxy request", { path: redactedUrl });
|
|
3417
|
+
const keyResult = extractApiKey(req.headers, url.searchParams);
|
|
3418
|
+
if (typeof keyResult !== "string") {
|
|
3419
|
+
if (req.method === "POST") {
|
|
3420
|
+
const rawCt = req.headers["content-type"];
|
|
3421
|
+
const ctHeader = typeof rawCt === "string" ? rawCt : Array.isArray(rawCt) ? rawCt[0] ?? "" : "";
|
|
3422
|
+
if (ctHeader.includes("application/json")) {
|
|
3423
|
+
try {
|
|
3424
|
+
const body = await bufferFromRequest(req, MCP_PROXY_MAX_BODY_BYTES);
|
|
3425
|
+
const bodyText = body.toString("utf8");
|
|
3426
|
+
const rpc = body.length > 0 ? parseJsonRpcLine(bodyText) : null;
|
|
3427
|
+
logger.info("[DEBUG] Unauthenticated POST body", {
|
|
3428
|
+
url: redactedUrl,
|
|
3429
|
+
bodyPreview: bodyText.slice(0, 500),
|
|
3430
|
+
parsedMethod: rpc?.method ?? null
|
|
3431
|
+
});
|
|
3432
|
+
if (body.length > 0) {
|
|
3433
|
+
const handshakeKind = classifyUnauthenticatedMcpHandshake(rpc);
|
|
3434
|
+
if (handshakeKind === "initialize" && rpc !== null) {
|
|
3435
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3436
|
+
res.end(buildUnauthenticatedInitializeResponse(rpc));
|
|
3437
|
+
return;
|
|
3438
|
+
}
|
|
3439
|
+
if (handshakeKind === "initialized_notification") {
|
|
3440
|
+
res.writeHead(204);
|
|
3441
|
+
res.end();
|
|
3442
|
+
return;
|
|
3443
|
+
}
|
|
3444
|
+
if ((handshakeKind === "tools_list" || handshakeKind === "prompts_list" || handshakeKind === "resources_list") && rpc !== null) {
|
|
3445
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3446
|
+
res.end(buildUnauthenticatedDiscoveryListResponse(rpc, handshakeKind));
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
} catch (readErr) {
|
|
3451
|
+
if (!res.headersSent) {
|
|
3452
|
+
if (readErr instanceof Error && readErr.message === "request body too large") {
|
|
3453
|
+
res.writeHead(413).end();
|
|
3454
|
+
} else {
|
|
3455
|
+
res.writeHead(400).end();
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
return;
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
res.writeHead(401, { "Content-Type": "application/problem+json" });
|
|
3463
|
+
res.end(
|
|
3464
|
+
JSON.stringify({
|
|
3465
|
+
type: "https://multicorn.ai/errors/unauthorized",
|
|
3466
|
+
title: "Unauthorized",
|
|
3467
|
+
status: 401,
|
|
3468
|
+
detail: "Missing or invalid Authorization / X-Multicorn-Key / key query parameter"
|
|
3469
|
+
})
|
|
3470
|
+
);
|
|
3471
|
+
return;
|
|
3472
|
+
}
|
|
3473
|
+
await handleProxied(req, res, route, keyResult);
|
|
3474
|
+
} catch (err) {
|
|
3475
|
+
logger.error("Unhandled server error.", {
|
|
3476
|
+
error: err instanceof Error ? err.message : String(err)
|
|
3477
|
+
});
|
|
3478
|
+
if (!res.headersSent) {
|
|
3479
|
+
res.writeHead(500).end();
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
3482
|
+
})();
|
|
3483
|
+
});
|
|
3484
|
+
applyProxyServerTimeouts(server, env);
|
|
3485
|
+
const listenCb = () => {
|
|
3486
|
+
logger.info("Hosted Shield proxy listening.", { port: env.port, host: env.host ?? "0.0.0.0", shieldApiBaseUrl: env.shieldApiBaseUrl });
|
|
3487
|
+
};
|
|
3488
|
+
if (env.host !== void 0) {
|
|
3489
|
+
server.listen(env.port, env.host, listenCb);
|
|
3490
|
+
} else {
|
|
3491
|
+
server.listen(env.port, listenCb);
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
var entryScript = process.argv[1];
|
|
3495
|
+
if (entryScript !== void 0 && fileURLToPath(import.meta.url) === entryScript) {
|
|
3496
|
+
main();
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3499
|
+
export { applyProxyServerTimeouts };
|