@promptev/context-engine 0.0.0 → 0.0.2
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/README.md +116 -5
- package/dist/cli.js +1896 -552
- package/dist/cli.js.map +1 -1
- package/dist/{config-Bl9U789m.d.cts → config-BODDdXJ7.d.ts} +75 -16
- package/dist/{config-Bt9bUQqU.d.ts → config-C5RZ00W6.d.cts} +75 -16
- package/dist/express.cjs +925 -151
- package/dist/express.cjs.map +1 -1
- package/dist/express.d.cts +11 -4
- package/dist/express.d.ts +11 -4
- package/dist/express.js +926 -152
- package/dist/express.js.map +1 -1
- package/dist/fastify.cjs +923 -151
- package/dist/fastify.cjs.map +1 -1
- package/dist/fastify.d.cts +8 -4
- package/dist/fastify.d.ts +8 -4
- package/dist/fastify.js +924 -152
- package/dist/fastify.js.map +1 -1
- package/dist/{governance-XIScatRO.d.ts → governance-BLPK7NMe.d.ts} +8 -2
- package/dist/{governance-BDkcv4qZ.d.cts → governance-P9pRb4Ol.d.cts} +8 -2
- package/dist/graph/index.cjs +171 -59
- package/dist/graph/index.cjs.map +1 -1
- package/dist/graph/index.d.cts +5 -3
- package/dist/graph/index.d.ts +5 -3
- package/dist/graph/index.js +171 -59
- package/dist/graph/index.js.map +1 -1
- package/dist/hono.cjs +923 -151
- package/dist/hono.cjs.map +1 -1
- package/dist/hono.d.cts +8 -4
- package/dist/hono.d.ts +8 -4
- package/dist/hono.js +924 -152
- package/dist/hono.js.map +1 -1
- package/dist/index.cjs +2413 -1052
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +125 -107
- package/dist/index.d.ts +125 -107
- package/dist/index.js +2412 -1050
- package/dist/index.js.map +1 -1
- package/dist/mcp.cjs +100 -14
- package/dist/mcp.cjs.map +1 -1
- package/dist/mcp.d.cts +5 -0
- package/dist/mcp.d.ts +5 -0
- package/dist/mcp.js +100 -14
- package/dist/mcp.js.map +1 -1
- package/dist/migrations/sql/0003_tools.sql +2 -0
- package/dist/migrations/sql/0004_acl_indexes.sql +23 -2
- package/dist/{redaction-BmDSWJ7h.d.cts → redaction-BqD_DEUQ.d.cts} +22 -1
- package/dist/{redaction-BmDSWJ7h.d.ts → redaction-BqD_DEUQ.d.ts} +22 -1
- package/dist/redaction-presidio.d.cts +1 -1
- package/dist/redaction-presidio.d.ts +1 -1
- package/dist/{router-CrxZ2y_Z.d.ts → router-CiFwC-EN.d.cts} +17 -2
- package/dist/{router-OPgSoYAB.d.cts → router-D8gBzwLd.d.ts} +17 -2
- package/dist/skills/context-engine/SKILL.md +5 -1
- package/dist/storage-CJrKgJeJ.d.ts +167 -0
- package/dist/storage-Dvpq2xAC.d.cts +167 -0
- package/package.json +61 -23
- package/src/migrations/sql/0003_tools.sql +2 -0
- package/src/migrations/sql/0004_acl_indexes.sql +23 -2
- package/src/skills/context-engine/SKILL.md +5 -1
- package/dist/embeddings-B-jZ42mk.d.cts +0 -67
- package/dist/embeddings-DaSdAZN3.d.ts +0 -67
package/dist/fastify.cjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
var module$1 = require('module');
|
|
4
4
|
var zod = require('zod');
|
|
5
5
|
var crypto = require('crypto');
|
|
6
|
+
var dns = require('dns');
|
|
7
|
+
var net = require('net');
|
|
6
8
|
|
|
7
9
|
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
8
10
|
var __defProp = Object.defineProperty;
|
|
@@ -61,6 +63,9 @@ __export(db_exports, {
|
|
|
61
63
|
function stripSqlNoise(sql) {
|
|
62
64
|
return sql.replace(SQL_NOISE_RE, (m) => " ".repeat(m.length));
|
|
63
65
|
}
|
|
66
|
+
function stripSqlNoiseBackslash(sql) {
|
|
67
|
+
return sql.replace(SQL_NOISE_BACKSLASH_RE, (m) => " ".repeat(m.length));
|
|
68
|
+
}
|
|
64
69
|
function hasKeyword(sqlUpper, keyword) {
|
|
65
70
|
return new RegExp(`\\b${keyword}\\b`).test(sqlUpper);
|
|
66
71
|
}
|
|
@@ -189,24 +194,28 @@ async function executeQueryAsync(config, sql, maxRows, accessModeRaw) {
|
|
|
189
194
|
`Invalid access_mode ${JSON.stringify(accessModeRaw)}; must be one of ${ALLOWED_ACCESS_MODES}`
|
|
190
195
|
);
|
|
191
196
|
}
|
|
192
|
-
const
|
|
197
|
+
const sqlVariants = [
|
|
198
|
+
stripSqlNoise(sql).trim().toUpperCase(),
|
|
199
|
+
stripSqlNoiseBackslash(sql).trim().toUpperCase()
|
|
200
|
+
];
|
|
201
|
+
const sqlUpper = sqlVariants[0];
|
|
193
202
|
for (const keyword of ALWAYS_BLOCKED) {
|
|
194
|
-
if (hasKeyword(
|
|
203
|
+
if ([...sqlVariants, sql.toUpperCase()].some((v) => hasKeyword(v, keyword))) {
|
|
195
204
|
return { success: false, error: `${keyword} queries are not allowed` };
|
|
196
205
|
}
|
|
197
206
|
}
|
|
198
207
|
if (accessMode === "readonly") {
|
|
199
|
-
if (!
|
|
208
|
+
if (!sqlVariants.every((v) => v.startsWith("SELECT") || v.startsWith("WITH"))) {
|
|
200
209
|
return { success: false, error: "Only SELECT queries are allowed in read-only mode" };
|
|
201
210
|
}
|
|
202
211
|
for (const keyword of READONLY_BLOCKED) {
|
|
203
|
-
if (hasKeyword(
|
|
212
|
+
if (sqlVariants.some((v) => hasKeyword(v, keyword))) {
|
|
204
213
|
return { success: false, error: `${keyword} queries are not allowed in read-only mode` };
|
|
205
214
|
}
|
|
206
215
|
}
|
|
207
216
|
} else if (accessMode === "readwrite") {
|
|
208
217
|
for (const keyword of READWRITE_BLOCKED) {
|
|
209
|
-
if (hasKeyword(
|
|
218
|
+
if (sqlVariants.some((v) => hasKeyword(v, keyword))) {
|
|
210
219
|
return { success: false, error: `${keyword} queries are not allowed in read-write mode` };
|
|
211
220
|
}
|
|
212
221
|
}
|
|
@@ -340,7 +349,7 @@ async function getSchemaText(config, selectedTables) {
|
|
|
340
349
|
}
|
|
341
350
|
return rowsToText(schemaRows);
|
|
342
351
|
}
|
|
343
|
-
var require2, MAX_ROWS, CONNECT_TIMEOUT, QUERY_TIMEOUT_MS, ALWAYS_BLOCKED, READONLY_BLOCKED, READWRITE_BLOCKED, ALLOWED_ACCESS_MODES, SQL_NOISE_RE;
|
|
352
|
+
var require2, MAX_ROWS, CONNECT_TIMEOUT, QUERY_TIMEOUT_MS, ALWAYS_BLOCKED, READONLY_BLOCKED, READWRITE_BLOCKED, ALLOWED_ACCESS_MODES, SQL_NOISE_RE, SQL_NOISE_BACKSLASH_RE;
|
|
344
353
|
var init_db = __esm({
|
|
345
354
|
"src/tools/executors/db.ts"() {
|
|
346
355
|
init_errors();
|
|
@@ -349,10 +358,22 @@ var init_db = __esm({
|
|
|
349
358
|
CONNECT_TIMEOUT = 10;
|
|
350
359
|
QUERY_TIMEOUT_MS = 3e4;
|
|
351
360
|
ALWAYS_BLOCKED = ["GRANT", "REVOKE"];
|
|
352
|
-
READONLY_BLOCKED = [
|
|
353
|
-
|
|
361
|
+
READONLY_BLOCKED = [
|
|
362
|
+
"INSERT",
|
|
363
|
+
"UPDATE",
|
|
364
|
+
"DELETE",
|
|
365
|
+
"MERGE",
|
|
366
|
+
"DROP",
|
|
367
|
+
"ALTER",
|
|
368
|
+
"CREATE",
|
|
369
|
+
"TRUNCATE",
|
|
370
|
+
"DO",
|
|
371
|
+
"CALL"
|
|
372
|
+
];
|
|
373
|
+
READWRITE_BLOCKED = ["DELETE", "DROP", "TRUNCATE", "DO", "CALL"];
|
|
354
374
|
ALLOWED_ACCESS_MODES = ["readonly", "readwrite", "full"];
|
|
355
|
-
SQL_NOISE_RE =
|
|
375
|
+
SQL_NOISE_RE = /\b[eE]'(?:[^'\\]|\\[\s\S]|'')*'|'(?:[^']|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
|
|
376
|
+
SQL_NOISE_BACKSLASH_RE = /'(?:[^'\\]|\\[\s\S]|'')*'|--[^\n]*|\/\*[\s\S]*?\*\//g;
|
|
356
377
|
}
|
|
357
378
|
});
|
|
358
379
|
|
|
@@ -360,16 +381,7 @@ var init_db = __esm({
|
|
|
360
381
|
init_errors();
|
|
361
382
|
|
|
362
383
|
// src/sentinels.ts
|
|
363
|
-
var
|
|
364
|
-
[Symbol.toStringTag] = "TRUSTED";
|
|
365
|
-
toString() {
|
|
366
|
-
return "TRUSTED";
|
|
367
|
-
}
|
|
368
|
-
valueOf() {
|
|
369
|
-
return true;
|
|
370
|
-
}
|
|
371
|
-
};
|
|
372
|
-
var TRUSTED = Object.freeze(new TrustedSentinel());
|
|
384
|
+
var TRUSTED = /* @__PURE__ */ Symbol.for("context_engine.TRUSTED");
|
|
373
385
|
|
|
374
386
|
// src/routing-core.ts
|
|
375
387
|
var HandlerError = class extends Error {
|
|
@@ -403,6 +415,10 @@ var documentPatchSchema = zod.z.object({
|
|
|
403
415
|
var searchRequestSchema = zod.z.object({
|
|
404
416
|
query: zod.z.string(),
|
|
405
417
|
source_ids: zod.z.array(zod.z.string()).nullable().optional(),
|
|
418
|
+
// Narrows WITHIN a source and INTERSECTS with source_ids — it can only
|
|
419
|
+
// shrink the result set (the ACL predicate still applies in the same SQL
|
|
420
|
+
// conjunction), so exposing it needs no authorizeAcl-style grant check.
|
|
421
|
+
document_ids: zod.z.array(zod.z.string()).nullable().optional(),
|
|
406
422
|
top_k: zod.z.number().int().optional().default(10),
|
|
407
423
|
mode: zod.z.enum(["hybrid", "graph"]).optional().default("hybrid"),
|
|
408
424
|
compress_to_tokens: zod.z.number().int().nullable().optional()
|
|
@@ -431,17 +447,23 @@ function formStr(value) {
|
|
|
431
447
|
return value === void 0 || value === null || value === "" ? null : String(value);
|
|
432
448
|
}
|
|
433
449
|
function resolveRequestPrincipals(value, opts) {
|
|
434
|
-
if (value === TRUSTED) return
|
|
450
|
+
if (value === TRUSTED) return TRUSTED;
|
|
435
451
|
if (value == null) {
|
|
436
452
|
throw new HandlerError(
|
|
437
453
|
500,
|
|
438
454
|
`${opts.surface}: the \`principals\` dependency returned null, which means TRUSTED CALLER \u2014 it disables ACL filtering and skips the check that stops a caller filing documents under groups it does not hold. This mount is misconfigured: return [] for an unauthenticated caller, or pass TRUSTED explicitly (from @promptev/context-engine) if the surface really is trusted.`
|
|
439
455
|
);
|
|
440
456
|
}
|
|
457
|
+
if (!Array.isArray(value) || value.some((p) => typeof p !== "string")) {
|
|
458
|
+
throw new HandlerError(
|
|
459
|
+
500,
|
|
460
|
+
`${opts.surface}: the \`principals\` dependency must return a list of principal strings, [] for an anonymous caller, or TRUSTED \u2014 got ${typeof value}.`
|
|
461
|
+
);
|
|
462
|
+
}
|
|
441
463
|
return value;
|
|
442
464
|
}
|
|
443
465
|
function authorizeAcl(requested, principals) {
|
|
444
|
-
if (requested == null || principals == null) return;
|
|
466
|
+
if (requested == null || principals == null || principals === TRUSTED) return;
|
|
445
467
|
if (!Array.isArray(requested)) {
|
|
446
468
|
throw new HandlerError(422, "acl must be a list of principal strings");
|
|
447
469
|
}
|
|
@@ -590,9 +612,11 @@ async function handleSearch(engine, payload, principals) {
|
|
|
590
612
|
const parsed = searchRequestSchema.safeParse(payload);
|
|
591
613
|
if (!parsed.success) throw zodError(parsed.error);
|
|
592
614
|
const body = parsed.data;
|
|
615
|
+
for (const did of body.document_ids ?? []) requireValidUuid(did);
|
|
593
616
|
try {
|
|
594
617
|
const result = await engine.search(body.query, {
|
|
595
618
|
sourceIds: body.source_ids,
|
|
619
|
+
documentIds: body.document_ids,
|
|
596
620
|
principals: resolved,
|
|
597
621
|
topK: body.top_k,
|
|
598
622
|
mode: body.mode,
|
|
@@ -616,7 +640,7 @@ init_errors();
|
|
|
616
640
|
|
|
617
641
|
// src/tools/acl.ts
|
|
618
642
|
function aclVisible(acl, principals) {
|
|
619
|
-
if (principals === null) return true;
|
|
643
|
+
if (principals === null || principals === TRUSTED) return true;
|
|
620
644
|
if (acl == null) return true;
|
|
621
645
|
if (!acl.length) return false;
|
|
622
646
|
const held = new Set(principals ?? []);
|
|
@@ -681,17 +705,24 @@ function rowToRecord(row) {
|
|
|
681
705
|
toolName: String(row.tool_name),
|
|
682
706
|
toolArgsFrozen: row.tool_args_frozen ?? {},
|
|
683
707
|
sourceId: row.source_id ?? null,
|
|
708
|
+
approvalScope: row.approval_scope ?? null,
|
|
684
709
|
principals: row.principals,
|
|
685
710
|
status: String(row.status),
|
|
686
711
|
approver: row.approver ?? null,
|
|
687
712
|
approverMeta: row.approver_meta ?? null,
|
|
688
|
-
expiresAt:
|
|
689
|
-
resolvedAt:
|
|
690
|
-
createdAt:
|
|
713
|
+
expiresAt: toDate(row.expires_at),
|
|
714
|
+
resolvedAt: toDate(row.resolved_at),
|
|
715
|
+
createdAt: toDate(row.created_at)
|
|
691
716
|
};
|
|
692
717
|
}
|
|
718
|
+
function toDate(value) {
|
|
719
|
+
if (value == null) return null;
|
|
720
|
+
if (value instanceof Date) return value;
|
|
721
|
+
return new Date(String(value));
|
|
722
|
+
}
|
|
693
723
|
function visibilitySql(principals, paramIndex) {
|
|
694
|
-
if (principals === null) return { sql: "TRUE", params: [] };
|
|
724
|
+
if (principals === null || principals === TRUSTED) return { sql: "TRUE", params: [] };
|
|
725
|
+
principals = principals;
|
|
695
726
|
if (!principals.length) {
|
|
696
727
|
return { sql: `(principals IS NULL OR principals = 'null'::jsonb)`, params: [] };
|
|
697
728
|
}
|
|
@@ -700,41 +731,95 @@ function visibilitySql(principals, paramIndex) {
|
|
|
700
731
|
params: [principals]
|
|
701
732
|
};
|
|
702
733
|
}
|
|
703
|
-
|
|
734
|
+
function claimVisibilitySql(principals, paramIndex) {
|
|
735
|
+
if (principals === null || principals === TRUSTED) return { sql: "TRUE", params: [] };
|
|
736
|
+
const noWall = `principals IS NULL OR principals = 'null'::jsonb OR principals = '[]'::jsonb`;
|
|
737
|
+
principals = principals;
|
|
738
|
+
if (!principals.length) return { sql: `(${noWall})`, params: [] };
|
|
739
|
+
return { sql: `(${noWall} OR principals ?| $${paramIndex}::text[])`, params: [principals] };
|
|
740
|
+
}
|
|
741
|
+
function validateApprovalScope(approvalScope) {
|
|
742
|
+
if (approvalScope === null || approvalScope === void 0) return null;
|
|
743
|
+
if (typeof approvalScope !== "string") {
|
|
744
|
+
throw new TypeError(`approvalScope must be a non-empty string or null, got ${typeof approvalScope}`);
|
|
745
|
+
}
|
|
746
|
+
if (!approvalScope) throw new Error("approvalScope must be a non-empty string or null, got ''");
|
|
747
|
+
return approvalScope;
|
|
748
|
+
}
|
|
749
|
+
function pendingRow(opts, approvalScope) {
|
|
704
750
|
const policy = opts.policy ?? {};
|
|
705
751
|
const frozen = structuredClone(opts.args);
|
|
706
752
|
const createdAt = opts.now ?? /* @__PURE__ */ new Date();
|
|
707
753
|
const timeout = Number(policy.timeout_minutes ?? DEFAULT_TIMEOUT_MINUTES);
|
|
708
754
|
const expiresAt = new Date(createdAt.getTime() + timeout * 6e4);
|
|
755
|
+
const principals = opts.principals === void 0 || opts.principals === TRUSTED ? null : opts.principals;
|
|
756
|
+
return { frozen, createdAt, expiresAt, principals, approvalScope };
|
|
757
|
+
}
|
|
758
|
+
async function insertPending(engine, opts, row) {
|
|
709
759
|
const id = crypto.randomUUID();
|
|
710
760
|
await engine.pool.query(
|
|
711
761
|
`INSERT INTO context_engine_tool_approvals
|
|
712
|
-
(id, tool_name, tool_args_frozen, source_id, principals, status, expires_at, created_at)
|
|
713
|
-
VALUES ($1,$2,$3::jsonb,$4,$5::jsonb,'pending',$
|
|
762
|
+
(id, tool_name, tool_args_frozen, source_id, approval_scope, principals, status, expires_at, created_at)
|
|
763
|
+
VALUES ($1,$2,$3::jsonb,$4,$5,$6::jsonb,'pending',$7,$8)`,
|
|
714
764
|
[
|
|
715
765
|
id,
|
|
716
766
|
opts.toolName,
|
|
717
|
-
JSON.stringify(frozen),
|
|
767
|
+
JSON.stringify(row.frozen),
|
|
718
768
|
opts.sourceId ?? null,
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
769
|
+
row.approvalScope,
|
|
770
|
+
row.principals === null ? null : JSON.stringify(row.principals),
|
|
771
|
+
row.expiresAt,
|
|
772
|
+
row.createdAt
|
|
722
773
|
]
|
|
723
774
|
);
|
|
724
775
|
return {
|
|
725
776
|
id,
|
|
726
777
|
toolName: opts.toolName,
|
|
727
|
-
toolArgsFrozen: frozen,
|
|
778
|
+
toolArgsFrozen: row.frozen,
|
|
728
779
|
sourceId: opts.sourceId ?? null,
|
|
729
|
-
|
|
780
|
+
approvalScope: row.approvalScope,
|
|
781
|
+
principals: row.principals,
|
|
730
782
|
status: "pending",
|
|
731
783
|
approver: null,
|
|
732
784
|
approverMeta: null,
|
|
733
|
-
expiresAt,
|
|
785
|
+
expiresAt: row.expiresAt,
|
|
734
786
|
resolvedAt: null,
|
|
735
|
-
createdAt
|
|
787
|
+
createdAt: row.createdAt
|
|
736
788
|
};
|
|
737
789
|
}
|
|
790
|
+
async function createPending(engine, opts) {
|
|
791
|
+
const approvalScope = validateApprovalScope(opts.approvalScope);
|
|
792
|
+
return insertPending(engine, opts, pendingRow(opts, approvalScope));
|
|
793
|
+
}
|
|
794
|
+
async function findOrCreatePending(engine, opts) {
|
|
795
|
+
const approvalScope = validateApprovalScope(opts.approvalScope);
|
|
796
|
+
if (approvalScope === null)
|
|
797
|
+
throw new Error("findOrCreatePending requires an approvalScope; use createPending");
|
|
798
|
+
const row = pendingRow(opts, approvalScope);
|
|
799
|
+
const frozenJson = JSON.stringify(row.frozen);
|
|
800
|
+
const sameCall = `tool_name = $1 AND approval_scope = $2 AND tool_args_frozen = $3::jsonb`;
|
|
801
|
+
const params = [opts.toolName, approvalScope, frozenJson, row.createdAt];
|
|
802
|
+
await engine.pool.query(
|
|
803
|
+
`UPDATE context_engine_tool_approvals SET status = 'expired'
|
|
804
|
+
WHERE ${sameCall} AND status = 'pending' AND expires_at <= $4`,
|
|
805
|
+
params
|
|
806
|
+
);
|
|
807
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
808
|
+
const found = await engine.pool.query(
|
|
809
|
+
`SELECT * FROM context_engine_tool_approvals
|
|
810
|
+
WHERE ${sameCall} AND status = 'pending' AND (expires_at IS NULL OR expires_at > $4)
|
|
811
|
+
ORDER BY created_at ASC LIMIT 1`,
|
|
812
|
+
params
|
|
813
|
+
);
|
|
814
|
+
if (found.rows[0]) return rowToRecord(found.rows[0]);
|
|
815
|
+
try {
|
|
816
|
+
return await insertPending(engine, opts, row);
|
|
817
|
+
} catch (exc) {
|
|
818
|
+
if (exc?.code !== "23505") throw exc;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
throw new Error("findOrCreatePending: lost the insert race twice and found no live pending row");
|
|
822
|
+
}
|
|
738
823
|
async function resolveApproval(engine, approvalId, decision, approver, meta = null, opts = {}) {
|
|
739
824
|
if (decision !== "approved" && decision !== "rejected") {
|
|
740
825
|
throw new Error(`invalid decision: ${JSON.stringify(decision)} (expected 'approved' or 'rejected')`);
|
|
@@ -781,6 +866,10 @@ async function listApprovals(engine, opts = {}) {
|
|
|
781
866
|
params.push(opts.sourceId);
|
|
782
867
|
clauses.push(`source_id = $${params.length}`);
|
|
783
868
|
}
|
|
869
|
+
if (opts.approvalScope != null) {
|
|
870
|
+
params.push(opts.approvalScope);
|
|
871
|
+
clauses.push(`approval_scope = $${params.length}`);
|
|
872
|
+
}
|
|
784
873
|
const vis = visibilitySql(principals, params.length + 1);
|
|
785
874
|
clauses.push(vis.sql);
|
|
786
875
|
params.push(...vis.params);
|
|
@@ -808,6 +897,10 @@ var ToolConfig = class _ToolConfig {
|
|
|
808
897
|
requiresApproval;
|
|
809
898
|
approvalPolicy;
|
|
810
899
|
enabled;
|
|
900
|
+
/** Engine-opaque user metadata (documents have the same column). Stored
|
|
901
|
+
* and returned in CLEAR on the admin surface only — secrets go in
|
|
902
|
+
* `config`, which is encrypted. */
|
|
903
|
+
metaData;
|
|
811
904
|
constructor(init) {
|
|
812
905
|
this.id = init.id ?? null;
|
|
813
906
|
this.name = init.name;
|
|
@@ -819,6 +912,7 @@ var ToolConfig = class _ToolConfig {
|
|
|
819
912
|
this.requiresApproval = init.requiresApproval ?? init.requires_approval ?? false;
|
|
820
913
|
this.approvalPolicy = init.approvalPolicy ?? init.approval_policy ?? {};
|
|
821
914
|
this.enabled = init.enabled ?? true;
|
|
915
|
+
this.metaData = init.metaData ?? init.meta_data ?? {};
|
|
822
916
|
}
|
|
823
917
|
static fromUnknown(body) {
|
|
824
918
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
@@ -841,7 +935,8 @@ var ToolConfig = class _ToolConfig {
|
|
|
841
935
|
acl: b.acl ?? null,
|
|
842
936
|
requiresApproval: Boolean(b.requiresApproval ?? b.requires_approval ?? false),
|
|
843
937
|
approvalPolicy: b.approvalPolicy ?? b.approval_policy ?? {},
|
|
844
|
-
enabled: b.enabled === void 0 ? true : Boolean(b.enabled)
|
|
938
|
+
enabled: b.enabled === void 0 ? true : Boolean(b.enabled),
|
|
939
|
+
metaData: b.metaData ?? b.meta_data ?? {}
|
|
845
940
|
});
|
|
846
941
|
}
|
|
847
942
|
};
|
|
@@ -860,9 +955,26 @@ var SCHEMAS = {
|
|
|
860
955
|
additionalProperties: { type: "string" },
|
|
861
956
|
writeOnly: true
|
|
862
957
|
},
|
|
958
|
+
// The request body TEMPLATE (the http executor reads it). Whether it
|
|
959
|
+
// round-trips is the USER's call — see `body_secret` and the
|
|
960
|
+
// conditional in `redactConfig`; listing it documents the shape, the
|
|
961
|
+
// redactor decides.
|
|
962
|
+
body: {
|
|
963
|
+
description: "Request body template; top-level keys merge with LLM parameters"
|
|
964
|
+
},
|
|
965
|
+
// Salman (2026-08-18): "give checkbox to user" — the author declares
|
|
966
|
+
// whether their body carries secrets. True (or ABSENT — every pre-flag
|
|
967
|
+
// row, fail closed) masks body values like headers; an explicit false
|
|
968
|
+
// round-trips the body in clear like `url`. The flag round-trips.
|
|
969
|
+
body_secret: {
|
|
970
|
+
type: "boolean",
|
|
971
|
+
description: "Body values are write-only (mask like headers)"
|
|
972
|
+
},
|
|
863
973
|
parameters: {
|
|
864
974
|
type: "object",
|
|
865
|
-
description: "Static/user-fixed parameters sent on every call"
|
|
975
|
+
description: "Static/user-fixed parameters sent on every call",
|
|
976
|
+
// The canonical home of a static api_key — secret by position.
|
|
977
|
+
writeOnly: true
|
|
866
978
|
},
|
|
867
979
|
llmParameters: {
|
|
868
980
|
type: "object",
|
|
@@ -893,7 +1005,13 @@ var SCHEMAS = {
|
|
|
893
1005
|
default: "readonly"
|
|
894
1006
|
},
|
|
895
1007
|
max_rows: { type: "integer", default: 1e3 },
|
|
896
|
-
selected_tables: { type: "array", items: { type: "string" } }
|
|
1008
|
+
selected_tables: { type: "array", items: { type: "string" } },
|
|
1009
|
+
// Dialect connection identifiers the db executor reads — same
|
|
1010
|
+
// sensitivity class as `database`, listed so they round-trip instead
|
|
1011
|
+
// of dying to redactConfig's default-deny.
|
|
1012
|
+
service_name: { type: "string" },
|
|
1013
|
+
schema_name: { type: "string" },
|
|
1014
|
+
warehouse: { type: "string" }
|
|
897
1015
|
},
|
|
898
1016
|
required: ["engine", "database"]
|
|
899
1017
|
},
|
|
@@ -925,11 +1043,306 @@ function configSchema(kind) {
|
|
|
925
1043
|
if (!schema) throw new Error(`unknown tool kind: ${JSON.stringify(kind)}`);
|
|
926
1044
|
return schema;
|
|
927
1045
|
}
|
|
1046
|
+
var REDACTED_SENTINEL = "__redacted__";
|
|
1047
|
+
var ConfigTemplateError = class extends Error {
|
|
1048
|
+
};
|
|
1049
|
+
function isPlainObject(value) {
|
|
1050
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1051
|
+
}
|
|
1052
|
+
function maskValue(value) {
|
|
1053
|
+
if (isPlainObject(value)) {
|
|
1054
|
+
const out = {};
|
|
1055
|
+
for (const [k, v] of Object.entries(value)) out[k] = maskValue(v);
|
|
1056
|
+
return out;
|
|
1057
|
+
}
|
|
1058
|
+
if (Array.isArray(value)) return value.map(maskValue);
|
|
1059
|
+
return REDACTED_SENTINEL;
|
|
1060
|
+
}
|
|
1061
|
+
function containsSentinel(value) {
|
|
1062
|
+
if (typeof value === "string") return value === REDACTED_SENTINEL;
|
|
1063
|
+
if (Array.isArray(value)) return value.some(containsSentinel);
|
|
1064
|
+
if (isPlainObject(value)) return Object.values(value).some(containsSentinel);
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
function redactConfig(kind, config) {
|
|
1068
|
+
const props = (SCHEMAS[kind]?.properties ?? {}) || {};
|
|
1069
|
+
const out = {};
|
|
1070
|
+
for (const [key, value] of Object.entries(config)) {
|
|
1071
|
+
if (kind === "http" && key === "body") {
|
|
1072
|
+
out[key] = config.body_secret === false ? structuredClone(value) : maskValue(value);
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
const spec = props[key];
|
|
1076
|
+
if (spec === void 0 || spec.writeOnly) {
|
|
1077
|
+
out[key] = maskValue(value);
|
|
1078
|
+
} else {
|
|
1079
|
+
out[key] = structuredClone(value);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
return out;
|
|
1083
|
+
}
|
|
1084
|
+
function mergeRedacted(incoming, stored) {
|
|
1085
|
+
const resolve = (value, kept, keptPresent, path) => {
|
|
1086
|
+
if (isPlainObject(value)) {
|
|
1087
|
+
const keptDict = isPlainObject(kept) ? kept : {};
|
|
1088
|
+
const out2 = {};
|
|
1089
|
+
for (const [k, v] of Object.entries(value)) {
|
|
1090
|
+
out2[k] = resolve(v, keptDict[k], k in keptDict, path ? `${path}.${k}` : k);
|
|
1091
|
+
}
|
|
1092
|
+
return out2;
|
|
1093
|
+
}
|
|
1094
|
+
if (Array.isArray(value)) {
|
|
1095
|
+
const keptList = Array.isArray(kept) ? kept : [];
|
|
1096
|
+
return value.map((v, i) => resolve(v, keptList[i], i < keptList.length, `${path}[${i}]`));
|
|
1097
|
+
}
|
|
1098
|
+
if (value === REDACTED_SENTINEL) {
|
|
1099
|
+
if (!keptPresent) {
|
|
1100
|
+
throw new ConfigTemplateError(
|
|
1101
|
+
`config.${path} is ${JSON.stringify(REDACTED_SENTINEL)} but there is no stored value to keep \u2014 re-enter the secret or omit the field`
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
return kept;
|
|
1105
|
+
}
|
|
1106
|
+
return value;
|
|
1107
|
+
};
|
|
1108
|
+
const keptRoot = stored ?? {};
|
|
1109
|
+
const out = {};
|
|
1110
|
+
for (const [k, v] of Object.entries(incoming)) {
|
|
1111
|
+
out[k] = resolve(v, keptRoot[k], k in keptRoot, k);
|
|
1112
|
+
}
|
|
1113
|
+
return out;
|
|
1114
|
+
}
|
|
1115
|
+
function encryptDict(data, key) {
|
|
1116
|
+
const nonce = crypto.randomBytes(12);
|
|
1117
|
+
const cipher = crypto.createCipheriv("aes-256-gcm", key, nonce);
|
|
1118
|
+
const plaintext = Buffer.from(JSON.stringify(data), "utf8");
|
|
1119
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
1120
|
+
const tag = cipher.getAuthTag();
|
|
1121
|
+
return Buffer.concat([nonce, ciphertext, tag]).toString("base64");
|
|
1122
|
+
}
|
|
1123
|
+
function decryptDict(token, key) {
|
|
1124
|
+
const raw = Buffer.from(token, "base64");
|
|
1125
|
+
const nonce = raw.subarray(0, 12);
|
|
1126
|
+
const tag = raw.subarray(raw.length - 16);
|
|
1127
|
+
const ciphertext = raw.subarray(12, raw.length - 16);
|
|
1128
|
+
const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
|
|
1129
|
+
decipher.setAuthTag(tag);
|
|
1130
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1131
|
+
return JSON.parse(plaintext.toString("utf8"));
|
|
1132
|
+
}
|
|
1133
|
+
function getSecretKey(config) {
|
|
1134
|
+
const secretKey = config.secretKey;
|
|
1135
|
+
if (!secretKey) {
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
"ContextEngineConfig.secretKey (env CE_SECRET_KEY) is not configured \u2014 a base64url-encoded 32-byte AES key is required to encrypt/decrypt tool configs that hold secrets."
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
return Buffer.from(secretKey, "base64url");
|
|
1141
|
+
}
|
|
1142
|
+
function hmacSha256Hex(key, value) {
|
|
1143
|
+
const k = typeof key === "string" ? Buffer.from(key) : key;
|
|
1144
|
+
return crypto.createHmac("sha256", k).update(value, "utf8").digest("hex");
|
|
1145
|
+
}
|
|
1146
|
+
var MAX_RESPONSE_BYTES = 1e6;
|
|
1147
|
+
var MAX_REDIRECTS = 5;
|
|
1148
|
+
function isRedirect(resp) {
|
|
1149
|
+
return resp.status >= 300 && resp.status < 400 || resp.type === "opaqueredirect";
|
|
1150
|
+
}
|
|
1151
|
+
var EgressDenied = class extends Error {
|
|
1152
|
+
constructor(message) {
|
|
1153
|
+
super(message);
|
|
1154
|
+
this.name = "EgressDenied";
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
var resolver = {
|
|
1158
|
+
async lookup(host) {
|
|
1159
|
+
const answers = await dns.promises.lookup(host, { all: true, verbatim: true });
|
|
1160
|
+
return answers.map((a) => a.address);
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
var V4_PRIVATE = [
|
|
1164
|
+
["0.0.0.0", 8],
|
|
1165
|
+
// "this network" / unspecified
|
|
1166
|
+
["10.0.0.0", 8],
|
|
1167
|
+
// RFC 1918
|
|
1168
|
+
["100.64.0.0", 10],
|
|
1169
|
+
// RFC 6598 carrier-grade NAT — overlay VPNs, pod CIDRs
|
|
1170
|
+
["127.0.0.0", 8],
|
|
1171
|
+
// loopback
|
|
1172
|
+
["169.254.0.0", 16],
|
|
1173
|
+
// link-local, incl. the cloud metadata address
|
|
1174
|
+
["172.16.0.0", 12],
|
|
1175
|
+
// RFC 1918
|
|
1176
|
+
["192.168.0.0", 16],
|
|
1177
|
+
// RFC 1918
|
|
1178
|
+
["224.0.0.0", 4],
|
|
1179
|
+
// multicast
|
|
1180
|
+
["240.0.0.0", 4],
|
|
1181
|
+
// reserved
|
|
1182
|
+
["255.255.255.255", 32]
|
|
1183
|
+
// broadcast (inside 240/4; spelled out anyway)
|
|
1184
|
+
];
|
|
1185
|
+
var V6_PRIVATE = [
|
|
1186
|
+
["::", 128],
|
|
1187
|
+
// unspecified
|
|
1188
|
+
["::1", 128],
|
|
1189
|
+
// loopback
|
|
1190
|
+
["::ffff:0:0", 96],
|
|
1191
|
+
// IPv4-mapped — the wrapped v4 is checked too
|
|
1192
|
+
["64:ff9b::", 96],
|
|
1193
|
+
// NAT64 — the wrapped v4 is checked too
|
|
1194
|
+
["2002::", 16],
|
|
1195
|
+
// 6to4 — the wrapped v4 is checked too, and this is denied
|
|
1196
|
+
["fc00::", 7],
|
|
1197
|
+
// unique local
|
|
1198
|
+
["fe80::", 10],
|
|
1199
|
+
// link-local
|
|
1200
|
+
["fec0::", 10],
|
|
1201
|
+
// site-local (deprecated, still configured)
|
|
1202
|
+
["ff00::", 8],
|
|
1203
|
+
// multicast
|
|
1204
|
+
["3fff::", 20]
|
|
1205
|
+
// documentation
|
|
1206
|
+
];
|
|
1207
|
+
function parseV4(text) {
|
|
1208
|
+
const parts = text.split(".");
|
|
1209
|
+
if (parts.length !== 4) return null;
|
|
1210
|
+
const out = new Uint8Array(4);
|
|
1211
|
+
for (let i = 0; i < 4; i++) {
|
|
1212
|
+
const part = parts[i];
|
|
1213
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
1214
|
+
const value = Number(part);
|
|
1215
|
+
if (value > 255) return null;
|
|
1216
|
+
out[i] = value;
|
|
1217
|
+
}
|
|
1218
|
+
return out;
|
|
1219
|
+
}
|
|
1220
|
+
function parseV6(text) {
|
|
1221
|
+
let body = text.split("%")[0] ?? "";
|
|
1222
|
+
const lastColon = body.lastIndexOf(":");
|
|
1223
|
+
if (lastColon < 0) return null;
|
|
1224
|
+
const tail = body.slice(lastColon + 1);
|
|
1225
|
+
if (tail.includes(".")) {
|
|
1226
|
+
const v4 = parseV4(tail);
|
|
1227
|
+
if (!v4) return null;
|
|
1228
|
+
const hi = (v4[0] << 8 | v4[1]).toString(16);
|
|
1229
|
+
const lo = (v4[2] << 8 | v4[3]).toString(16);
|
|
1230
|
+
body = `${body.slice(0, lastColon + 1)}${hi}:${lo}`;
|
|
1231
|
+
}
|
|
1232
|
+
const halves = body.split("::");
|
|
1233
|
+
if (halves.length > 2) return null;
|
|
1234
|
+
const head = halves[0] ? halves[0].split(":") : [];
|
|
1235
|
+
const rest = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
|
|
1236
|
+
let groups;
|
|
1237
|
+
if (halves.length === 1) {
|
|
1238
|
+
if (head.length !== 8) return null;
|
|
1239
|
+
groups = head;
|
|
1240
|
+
} else {
|
|
1241
|
+
const missing = 8 - head.length - rest.length;
|
|
1242
|
+
if (missing < 0) return null;
|
|
1243
|
+
groups = [...head, ...Array(missing).fill("0"), ...rest];
|
|
1244
|
+
}
|
|
1245
|
+
const out = new Uint8Array(16);
|
|
1246
|
+
for (let i = 0; i < 8; i++) {
|
|
1247
|
+
const group = groups[i];
|
|
1248
|
+
if (!/^[0-9a-f]{1,4}$/i.test(group)) return null;
|
|
1249
|
+
const value = Number.parseInt(group, 16);
|
|
1250
|
+
out[2 * i] = value >> 8;
|
|
1251
|
+
out[2 * i + 1] = value & 255;
|
|
1252
|
+
}
|
|
1253
|
+
return out;
|
|
1254
|
+
}
|
|
1255
|
+
function parseAddress(text) {
|
|
1256
|
+
const family = net.isIP(text);
|
|
1257
|
+
if (family === 4) return parseV4(text);
|
|
1258
|
+
if (family === 6) return parseV6(text);
|
|
1259
|
+
return null;
|
|
1260
|
+
}
|
|
1261
|
+
function inNet(addr, net, prefix) {
|
|
1262
|
+
if (addr.length !== net.length) return false;
|
|
1263
|
+
const whole = prefix >> 3;
|
|
1264
|
+
for (let i = 0; i < whole; i++) if (addr[i] !== net[i]) return false;
|
|
1265
|
+
const bits = prefix & 7;
|
|
1266
|
+
if (bits === 0) return true;
|
|
1267
|
+
const mask = 255 << 8 - bits;
|
|
1268
|
+
return (addr[whole] & mask) === (net[whole] & mask);
|
|
1269
|
+
}
|
|
1270
|
+
function compile(table) {
|
|
1271
|
+
return table.map(([cidr, prefix]) => {
|
|
1272
|
+
const bytes = parseAddress(cidr);
|
|
1273
|
+
if (!bytes) throw new Error(`egress: unparseable network ${cidr}`);
|
|
1274
|
+
return [bytes, prefix];
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
var V4_NETS = compile(V4_PRIVATE);
|
|
1278
|
+
var V6_NETS = compile(V6_PRIVATE);
|
|
1279
|
+
var MAPPED_V4 = compile([["::ffff:0:0", 96]])[0];
|
|
1280
|
+
var NAT64 = compile([["64:ff9b::", 96]])[0];
|
|
1281
|
+
var SIXTOFOUR = compile([["2002::", 16]])[0];
|
|
1282
|
+
function embeddedV4(bytes) {
|
|
1283
|
+
if (inNet(bytes, MAPPED_V4[0], MAPPED_V4[1]) || inNet(bytes, NAT64[0], NAT64[1])) {
|
|
1284
|
+
return bytes.subarray(12, 16);
|
|
1285
|
+
}
|
|
1286
|
+
if (inNet(bytes, SIXTOFOUR[0], SIXTOFOUR[1])) return bytes.subarray(2, 6);
|
|
1287
|
+
return null;
|
|
1288
|
+
}
|
|
1289
|
+
function addressIsPrivate(address) {
|
|
1290
|
+
const bytes = parseAddress(address);
|
|
1291
|
+
if (!bytes) return true;
|
|
1292
|
+
if (bytes.length === 4) return V4_NETS.some(([net, prefix]) => inNet(bytes, net, prefix));
|
|
1293
|
+
const embedded = embeddedV4(bytes);
|
|
1294
|
+
if (embedded && V4_NETS.some(([net, prefix]) => inNet(embedded, net, prefix))) return true;
|
|
1295
|
+
return V6_NETS.some(([net, prefix]) => inNet(bytes, net, prefix));
|
|
1296
|
+
}
|
|
1297
|
+
async function isPrivateAddress(host) {
|
|
1298
|
+
let name = (host ?? "").trim().toLowerCase().replace(/^\.+|\.+$/g, "");
|
|
1299
|
+
if (!name) return true;
|
|
1300
|
+
if (name === "localhost") return true;
|
|
1301
|
+
if (name.startsWith("[") && name.endsWith("]")) name = name.slice(1, -1);
|
|
1302
|
+
if (net.isIP(name)) return addressIsPrivate(name);
|
|
1303
|
+
let addresses;
|
|
1304
|
+
try {
|
|
1305
|
+
addresses = await resolver.lookup(name);
|
|
1306
|
+
} catch {
|
|
1307
|
+
return true;
|
|
1308
|
+
}
|
|
1309
|
+
if (!addresses.length) return true;
|
|
1310
|
+
return addresses.some((address) => addressIsPrivate(address.split("%")[0] ?? address));
|
|
1311
|
+
}
|
|
1312
|
+
async function assertEgressAllowed(url, opts) {
|
|
1313
|
+
let parsed;
|
|
1314
|
+
try {
|
|
1315
|
+
parsed = new URL(url ?? "");
|
|
1316
|
+
} catch {
|
|
1317
|
+
throw new EgressDenied(`egress denied: ${JSON.stringify(url)} is not a valid URL`);
|
|
1318
|
+
}
|
|
1319
|
+
const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
|
|
1320
|
+
if (scheme !== "http" && scheme !== "https") {
|
|
1321
|
+
throw new EgressDenied(
|
|
1322
|
+
`egress denied: unsupported URL scheme ${JSON.stringify(scheme)} \u2014 http tools may only reach http/https`
|
|
1323
|
+
);
|
|
1324
|
+
}
|
|
1325
|
+
if (opts.allowPrivate) return;
|
|
1326
|
+
const host = parsed.hostname;
|
|
1327
|
+
if (await isPrivateAddress(host)) {
|
|
1328
|
+
throw new EgressDenied(
|
|
1329
|
+
`egress denied: ${host || "(no host)"} is a private, loopback, link-local or metadata address; set allowPrivateEgress to permit it`
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
928
1333
|
|
|
929
1334
|
// src/version.ts
|
|
930
1335
|
var __version__ = "0.0.0";
|
|
931
1336
|
|
|
932
1337
|
// src/tools/executors/mcp-client.ts
|
|
1338
|
+
async function checkEgress(url, allowPrivate) {
|
|
1339
|
+
let checked = url;
|
|
1340
|
+
const scheme = (/^([a-z0-9+.-]+):/i.exec(url ?? "")?.[1] ?? "").toLowerCase();
|
|
1341
|
+
if (scheme === "ws") checked = `http:${url.slice(scheme.length + 1)}`;
|
|
1342
|
+
else if (scheme === "wss") checked = `https:${url.slice(scheme.length + 1)}`;
|
|
1343
|
+
await assertEgressAllowed(checked, { allowPrivate });
|
|
1344
|
+
}
|
|
1345
|
+
var REDIRECT_MESSAGE = "MCP server redirected; register the final URL";
|
|
933
1346
|
var MCPError = class _MCPError extends Error {
|
|
934
1347
|
code;
|
|
935
1348
|
data;
|
|
@@ -958,10 +1371,12 @@ function parseSseBuffer(raw) {
|
|
|
958
1371
|
var HttpTransport = class {
|
|
959
1372
|
kind = "http";
|
|
960
1373
|
url;
|
|
1374
|
+
allowPrivate;
|
|
961
1375
|
baseHeaders;
|
|
962
1376
|
sessionId = null;
|
|
963
|
-
constructor(url, headers) {
|
|
1377
|
+
constructor(url, headers, allowPrivate = false) {
|
|
964
1378
|
this.url = url;
|
|
1379
|
+
this.allowPrivate = allowPrivate;
|
|
965
1380
|
this.baseHeaders = {
|
|
966
1381
|
...headers,
|
|
967
1382
|
"Content-Type": "application/json",
|
|
@@ -969,6 +1384,7 @@ var HttpTransport = class {
|
|
|
969
1384
|
};
|
|
970
1385
|
}
|
|
971
1386
|
async connect() {
|
|
1387
|
+
await checkEgress(this.url, this.allowPrivate);
|
|
972
1388
|
}
|
|
973
1389
|
async close() {
|
|
974
1390
|
this.sessionId = null;
|
|
@@ -982,10 +1398,17 @@ var HttpTransport = class {
|
|
|
982
1398
|
const resp = await fetch(this.url, {
|
|
983
1399
|
method: "POST",
|
|
984
1400
|
headers: this.buildHeaders(),
|
|
985
|
-
body: JSON.stringify(msg)
|
|
1401
|
+
body: JSON.stringify(msg),
|
|
1402
|
+
// `redirect: "manual"`: a followed redirect would carry this POST —
|
|
1403
|
+
// headers, Authorization and all — to a destination `connect()`'s
|
|
1404
|
+
// check never saw. A 3xx is reported, never chased.
|
|
1405
|
+
redirect: "manual"
|
|
986
1406
|
});
|
|
987
1407
|
const sid = resp.headers.get("Mcp-Session-Id");
|
|
988
1408
|
if (sid) this.sessionId = sid;
|
|
1409
|
+
if (isRedirect(resp)) {
|
|
1410
|
+
throw new MCPError(resp.status, REDIRECT_MESSAGE);
|
|
1411
|
+
}
|
|
989
1412
|
if (resp.status >= 400) {
|
|
990
1413
|
const body = await resp.text();
|
|
991
1414
|
console.error(`[HTTP Transport] ${resp.status} from ${this.url}: ${body.slice(0, 300)}`);
|
|
@@ -1018,11 +1441,20 @@ var SseTransport = class {
|
|
|
1018
1441
|
eventsUrl;
|
|
1019
1442
|
baseUrl;
|
|
1020
1443
|
headers;
|
|
1444
|
+
allowPrivate;
|
|
1445
|
+
/** Where the server told us to POST — it arrives in an `endpoint` event on
|
|
1446
|
+
* the stream, so it is server-chosen and hence checked. Private: the only
|
|
1447
|
+
* way in is the parser, which is the path a real server takes. */
|
|
1021
1448
|
postUrl = null;
|
|
1449
|
+
/** The last POST endpoint cleared by the egress check. The check costs a
|
|
1450
|
+
* DNS resolution and the endpoint changes at most once per connection, so
|
|
1451
|
+
* it is not repeated per message. */
|
|
1452
|
+
checkedPostUrl = null;
|
|
1022
1453
|
queue = [];
|
|
1023
1454
|
waiters = [];
|
|
1024
1455
|
abort = null;
|
|
1025
|
-
constructor(url, headers) {
|
|
1456
|
+
constructor(url, headers, allowPrivate = false) {
|
|
1457
|
+
this.allowPrivate = allowPrivate;
|
|
1026
1458
|
this.eventsUrl = url.replace(/\/$/, "");
|
|
1027
1459
|
if (url.endsWith("/events")) this.baseUrl = url.slice(0, -7);
|
|
1028
1460
|
else if (url.endsWith("/sse")) this.baseUrl = url.slice(0, -4);
|
|
@@ -1035,14 +1467,19 @@ var SseTransport = class {
|
|
|
1035
1467
|
else this.queue.push(msg);
|
|
1036
1468
|
}
|
|
1037
1469
|
async connect() {
|
|
1470
|
+
await checkEgress(this.eventsUrl, this.allowPrivate);
|
|
1038
1471
|
this.abort = new AbortController();
|
|
1039
1472
|
void this.listen();
|
|
1040
1473
|
}
|
|
1041
1474
|
async listen() {
|
|
1042
1475
|
const resp = await fetch(this.eventsUrl, {
|
|
1043
1476
|
headers: { ...this.headers, Accept: "text/event-stream" },
|
|
1044
|
-
signal: this.abort?.signal
|
|
1477
|
+
signal: this.abort?.signal,
|
|
1478
|
+
redirect: "manual"
|
|
1045
1479
|
});
|
|
1480
|
+
if (isRedirect(resp)) {
|
|
1481
|
+
throw new MCPError(resp.status, REDIRECT_MESSAGE);
|
|
1482
|
+
}
|
|
1046
1483
|
if (!resp.ok) throw new Error(`SSE ${resp.status}`);
|
|
1047
1484
|
if (!resp.body) throw new Error("SSE response has no body");
|
|
1048
1485
|
const reader = resp.body.getReader();
|
|
@@ -1094,11 +1531,19 @@ var SseTransport = class {
|
|
|
1094
1531
|
}
|
|
1095
1532
|
}
|
|
1096
1533
|
const postUrl = this.postUrl || this.baseUrl;
|
|
1534
|
+
if (postUrl !== this.checkedPostUrl) {
|
|
1535
|
+
await checkEgress(postUrl, this.allowPrivate);
|
|
1536
|
+
this.checkedPostUrl = postUrl;
|
|
1537
|
+
}
|
|
1097
1538
|
const resp = await fetch(postUrl, {
|
|
1098
1539
|
method: "POST",
|
|
1099
1540
|
headers: { ...this.headers, "Content-Type": "application/json" },
|
|
1100
|
-
body: JSON.stringify(msg)
|
|
1541
|
+
body: JSON.stringify(msg),
|
|
1542
|
+
redirect: "manual"
|
|
1101
1543
|
});
|
|
1544
|
+
if (isRedirect(resp)) {
|
|
1545
|
+
throw new MCPError(resp.status, REDIRECT_MESSAGE);
|
|
1546
|
+
}
|
|
1102
1547
|
if (!resp.ok) throw new Error(`SSE POST ${resp.status}`);
|
|
1103
1548
|
try {
|
|
1104
1549
|
const data = await resp.json();
|
|
@@ -1117,14 +1562,17 @@ var WsTransport = class {
|
|
|
1117
1562
|
kind = "websocket";
|
|
1118
1563
|
url;
|
|
1119
1564
|
headers;
|
|
1565
|
+
allowPrivate;
|
|
1120
1566
|
ws = null;
|
|
1121
1567
|
queue = [];
|
|
1122
1568
|
waiters = [];
|
|
1123
|
-
constructor(url, headers) {
|
|
1569
|
+
constructor(url, headers, allowPrivate = false) {
|
|
1124
1570
|
this.url = url;
|
|
1571
|
+
this.allowPrivate = allowPrivate;
|
|
1125
1572
|
this.headers = headers;
|
|
1126
1573
|
}
|
|
1127
1574
|
async connect() {
|
|
1575
|
+
await checkEgress(this.url, this.allowPrivate);
|
|
1128
1576
|
const WS = globalThis.WebSocket;
|
|
1129
1577
|
if (!WS) throw new Error("WebSocket is not available in this runtime");
|
|
1130
1578
|
this.ws = new WS(this.url);
|
|
@@ -1165,21 +1613,27 @@ var MCPClient = class _MCPClient {
|
|
|
1165
1613
|
tools = [];
|
|
1166
1614
|
resources = [];
|
|
1167
1615
|
headers;
|
|
1168
|
-
|
|
1616
|
+
allowPrivate;
|
|
1617
|
+
constructor(url, headers = null, opts = {}) {
|
|
1169
1618
|
this.url = url;
|
|
1170
1619
|
this.headers = headers ?? {};
|
|
1171
|
-
this.
|
|
1620
|
+
this.allowPrivate = opts.allowPrivate ?? false;
|
|
1621
|
+
this.transport = _MCPClient.createTransport(url, this.headers, this.allowPrivate);
|
|
1172
1622
|
}
|
|
1173
|
-
static createTransport(url, headers) {
|
|
1623
|
+
static createTransport(url, headers, allowPrivate = false) {
|
|
1174
1624
|
if (url.startsWith("stdio:") || url === "stdio") {
|
|
1175
1625
|
throw new Error("stdio MCP transport is not supported (cloud deployments use SSE/HTTP/WebSocket)");
|
|
1176
1626
|
}
|
|
1177
|
-
if (url.startsWith("ws://") || url.startsWith("wss://"))
|
|
1627
|
+
if (url.startsWith("ws://") || url.startsWith("wss://")) {
|
|
1628
|
+
return new WsTransport(url, headers, allowPrivate);
|
|
1629
|
+
}
|
|
1178
1630
|
if (url.startsWith("sse+http://") || url.startsWith("sse+https://") || url.endsWith("/events") || url.endsWith("/sse")) {
|
|
1179
1631
|
const clean = url.replace("sse+http://", "http://").replace("sse+https://", "https://");
|
|
1180
|
-
return new SseTransport(clean, headers);
|
|
1632
|
+
return new SseTransport(clean, headers, allowPrivate);
|
|
1633
|
+
}
|
|
1634
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
1635
|
+
return new HttpTransport(url, headers, allowPrivate);
|
|
1181
1636
|
}
|
|
1182
|
-
if (url.startsWith("http://") || url.startsWith("https://")) return new HttpTransport(url, headers);
|
|
1183
1637
|
throw new Error(`Unsupported URL: ${url} (use http(s)://, ws(s)://, or .../sse)`);
|
|
1184
1638
|
}
|
|
1185
1639
|
nextId() {
|
|
@@ -1231,7 +1685,15 @@ var MCPClient = class _MCPClient {
|
|
|
1231
1685
|
await this.transport.send({ jsonrpc: "2.0", method, params: params ?? {} });
|
|
1232
1686
|
}
|
|
1233
1687
|
async connect() {
|
|
1234
|
-
|
|
1688
|
+
try {
|
|
1689
|
+
await this.transport.connect();
|
|
1690
|
+
} catch (e) {
|
|
1691
|
+
try {
|
|
1692
|
+
await this.transport.close();
|
|
1693
|
+
} catch {
|
|
1694
|
+
}
|
|
1695
|
+
throw e;
|
|
1696
|
+
}
|
|
1235
1697
|
if (!(this.transport instanceof HttpTransport)) {
|
|
1236
1698
|
this.connected = true;
|
|
1237
1699
|
void this.recvLoop();
|
|
@@ -1314,7 +1776,7 @@ var MCPClient = class _MCPClient {
|
|
|
1314
1776
|
}
|
|
1315
1777
|
this.id = 0;
|
|
1316
1778
|
this.pending.clear();
|
|
1317
|
-
this.transport = _MCPClient.createTransport(url, headers);
|
|
1779
|
+
this.transport = _MCPClient.createTransport(url, headers, this.allowPrivate);
|
|
1318
1780
|
await this.connect();
|
|
1319
1781
|
await this.listTools();
|
|
1320
1782
|
}
|
|
@@ -1322,11 +1784,11 @@ var MCPClient = class _MCPClient {
|
|
|
1322
1784
|
var MCPRegistry = class {
|
|
1323
1785
|
clients = /* @__PURE__ */ new Map();
|
|
1324
1786
|
healthTask = null;
|
|
1325
|
-
async connect(name, url, headers = null, token = null) {
|
|
1787
|
+
async connect(name, url, headers = null, token = null, allowPrivate = false) {
|
|
1326
1788
|
this.clients.delete(name);
|
|
1327
1789
|
const hdrs = { ...headers ?? {} };
|
|
1328
1790
|
if (token) hdrs.Authorization = hdrs.Authorization ?? `Bearer ${token}`;
|
|
1329
|
-
const client = new MCPClient(url, hdrs);
|
|
1791
|
+
const client = new MCPClient(url, hdrs, { allowPrivate });
|
|
1330
1792
|
await client.connect();
|
|
1331
1793
|
await client.listTools();
|
|
1332
1794
|
this.clients.set(name, client);
|
|
@@ -1378,8 +1840,18 @@ var MCPRegistry = class {
|
|
|
1378
1840
|
};
|
|
1379
1841
|
var PromptevMCP = class {
|
|
1380
1842
|
clients = new MCPRegistry();
|
|
1843
|
+
allowPrivate;
|
|
1844
|
+
/**
|
|
1845
|
+
* `allowPrivate` is the operator knob `allowPrivateEgress`, carried down to
|
|
1846
|
+
* every transport this facade builds. It defaults to `false`, so a
|
|
1847
|
+
* construction site that forgets to pass it denies private destinations
|
|
1848
|
+
* rather than permitting them.
|
|
1849
|
+
*/
|
|
1850
|
+
constructor(opts = {}) {
|
|
1851
|
+
this.allowPrivate = opts.allowPrivate ?? false;
|
|
1852
|
+
}
|
|
1381
1853
|
async addServer(name, url, opts = {}) {
|
|
1382
|
-
await this.clients.connect(name, url, opts.headers ?? null, opts.token ?? null);
|
|
1854
|
+
await this.clients.connect(name, url, opts.headers ?? null, opts.token ?? null, this.allowPrivate);
|
|
1383
1855
|
}
|
|
1384
1856
|
async shutdown() {
|
|
1385
1857
|
await this.clients.shutdown();
|
|
@@ -1419,37 +1891,6 @@ function emitToolCall(hooks, event) {
|
|
|
1419
1891
|
log.warn("onToolCall callback raised; swallowing");
|
|
1420
1892
|
}
|
|
1421
1893
|
}
|
|
1422
|
-
function encryptDict(data, key) {
|
|
1423
|
-
const nonce = crypto.randomBytes(12);
|
|
1424
|
-
const cipher = crypto.createCipheriv("aes-256-gcm", key, nonce);
|
|
1425
|
-
const plaintext = Buffer.from(JSON.stringify(data), "utf8");
|
|
1426
|
-
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
1427
|
-
const tag = cipher.getAuthTag();
|
|
1428
|
-
return Buffer.concat([nonce, ciphertext, tag]).toString("base64");
|
|
1429
|
-
}
|
|
1430
|
-
function decryptDict(token, key) {
|
|
1431
|
-
const raw = Buffer.from(token, "base64");
|
|
1432
|
-
const nonce = raw.subarray(0, 12);
|
|
1433
|
-
const tag = raw.subarray(raw.length - 16);
|
|
1434
|
-
const ciphertext = raw.subarray(12, raw.length - 16);
|
|
1435
|
-
const decipher = crypto.createDecipheriv("aes-256-gcm", key, nonce);
|
|
1436
|
-
decipher.setAuthTag(tag);
|
|
1437
|
-
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1438
|
-
return JSON.parse(plaintext.toString("utf8"));
|
|
1439
|
-
}
|
|
1440
|
-
function getSecretKey(config) {
|
|
1441
|
-
const secretKey = config.secretKey;
|
|
1442
|
-
if (!secretKey) {
|
|
1443
|
-
throw new Error(
|
|
1444
|
-
"ContextEngineConfig.secretKey (env CE_SECRET_KEY) is not configured \u2014 a base64url-encoded 32-byte AES key is required to encrypt/decrypt tool configs that hold secrets."
|
|
1445
|
-
);
|
|
1446
|
-
}
|
|
1447
|
-
return Buffer.from(secretKey, "base64url");
|
|
1448
|
-
}
|
|
1449
|
-
function hmacSha256Hex(key, value) {
|
|
1450
|
-
const k = typeof key === "string" ? Buffer.from(key) : key;
|
|
1451
|
-
return crypto.createHmac("sha256", k).update(value, "utf8").digest("hex");
|
|
1452
|
-
}
|
|
1453
1894
|
|
|
1454
1895
|
// src/redaction.ts
|
|
1455
1896
|
var EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
@@ -1729,6 +2170,7 @@ async function logToolCall(engine, row) {
|
|
|
1729
2170
|
init_db();
|
|
1730
2171
|
|
|
1731
2172
|
// src/tools/executors/http.ts
|
|
2173
|
+
init_errors();
|
|
1732
2174
|
var TIMEOUT_MS = 3e4;
|
|
1733
2175
|
var SENSITIVE_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
1734
2176
|
"set-cookie",
|
|
@@ -1737,6 +2179,8 @@ var SENSITIVE_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
|
1737
2179
|
"proxy-authenticate",
|
|
1738
2180
|
"www-authenticate"
|
|
1739
2181
|
]);
|
|
2182
|
+
var REDIRECT_STATUS = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
|
|
2183
|
+
var CROSS_ORIGIN_HEADERS = /* @__PURE__ */ new Set(["authorization", "proxy-authorization", "cookie"]);
|
|
1740
2184
|
function percentEncode(value) {
|
|
1741
2185
|
return encodeURIComponent(value).replace(
|
|
1742
2186
|
/[!'()*]/g,
|
|
@@ -1767,10 +2211,38 @@ function withQuery(url, params) {
|
|
|
1767
2211
|
if (!q) return url;
|
|
1768
2212
|
return url.includes("?") ? `${url}&${q}` : `${url}?${q}`;
|
|
1769
2213
|
}
|
|
2214
|
+
async function readCapped(resp) {
|
|
2215
|
+
if (!resp.body) return { text: await resp.text(), truncated: false };
|
|
2216
|
+
const reader = resp.body.getReader();
|
|
2217
|
+
const chunks = [];
|
|
2218
|
+
let size = 0;
|
|
2219
|
+
let truncated = false;
|
|
2220
|
+
for (; ; ) {
|
|
2221
|
+
const { done, value } = await reader.read();
|
|
2222
|
+
if (done) break;
|
|
2223
|
+
if (value) {
|
|
2224
|
+
chunks.push(Buffer.from(value));
|
|
2225
|
+
size += value.byteLength;
|
|
2226
|
+
}
|
|
2227
|
+
if (size > MAX_RESPONSE_BYTES) {
|
|
2228
|
+
truncated = true;
|
|
2229
|
+
await reader.cancel();
|
|
2230
|
+
break;
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
const bytes = Buffer.concat(chunks);
|
|
2234
|
+
return {
|
|
2235
|
+
text: (truncated ? bytes.subarray(0, MAX_RESPONSE_BYTES) : bytes).toString("utf8"),
|
|
2236
|
+
truncated
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
1770
2239
|
async function defaultRequest(init) {
|
|
1771
2240
|
let url = init.url;
|
|
1772
2241
|
if (init.params) url = withQuery(url, init.params);
|
|
1773
2242
|
const headers = { ...init.headers ?? {} };
|
|
2243
|
+
if (!Object.keys(headers).some((k) => k.toLowerCase() === "accept-encoding")) {
|
|
2244
|
+
headers["Accept-Encoding"] = "identity";
|
|
2245
|
+
}
|
|
1774
2246
|
let body;
|
|
1775
2247
|
if (init.json !== void 0) {
|
|
1776
2248
|
headers["Content-Type"] = headers["Content-Type"] ?? headers["content-type"] ?? "application/json";
|
|
@@ -1782,25 +2254,47 @@ async function defaultRequest(init) {
|
|
|
1782
2254
|
method: init.method,
|
|
1783
2255
|
headers,
|
|
1784
2256
|
body,
|
|
1785
|
-
|
|
2257
|
+
// Redirects are followed by hand in `executeHttp` — an automatic follow
|
|
2258
|
+
// would jump to a destination no egress check ever saw.
|
|
2259
|
+
redirect: "manual",
|
|
1786
2260
|
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
1787
2261
|
});
|
|
1788
2262
|
const respHeaders = {};
|
|
1789
2263
|
resp.headers.forEach((v, k) => {
|
|
1790
2264
|
respHeaders[k] = v;
|
|
1791
2265
|
});
|
|
1792
|
-
const text = await resp
|
|
2266
|
+
const { text, truncated } = await readCapped(resp);
|
|
1793
2267
|
return {
|
|
1794
2268
|
status: resp.status,
|
|
1795
2269
|
headers: respHeaders,
|
|
2270
|
+
truncated,
|
|
1796
2271
|
async json() {
|
|
1797
|
-
return JSON.parse(text);
|
|
2272
|
+
return JSON.parse(text.replace(/^/, ""));
|
|
1798
2273
|
},
|
|
1799
2274
|
async text() {
|
|
1800
2275
|
return text;
|
|
1801
2276
|
}
|
|
1802
2277
|
};
|
|
1803
2278
|
}
|
|
2279
|
+
function nextHop(init, status, location) {
|
|
2280
|
+
const current = new URL(init.url);
|
|
2281
|
+
const target = new URL(location, current);
|
|
2282
|
+
let method = init.method;
|
|
2283
|
+
if ((status === 302 || status === 303) && method !== "HEAD") method = "GET";
|
|
2284
|
+
else if (status === 301 && method === "POST") method = "GET";
|
|
2285
|
+
let headers = { ...init.headers ?? {} };
|
|
2286
|
+
if (current.origin !== target.origin) {
|
|
2287
|
+
headers = Object.fromEntries(
|
|
2288
|
+
Object.entries(headers).filter(([k]) => !CROSS_ORIGIN_HEADERS.has(k.toLowerCase()))
|
|
2289
|
+
);
|
|
2290
|
+
}
|
|
2291
|
+
const hop = { method, url: target.toString(), headers };
|
|
2292
|
+
if (method === init.method) {
|
|
2293
|
+
if (init.json !== void 0) hop.json = init.json;
|
|
2294
|
+
if (init.content !== void 0) hop.content = init.content;
|
|
2295
|
+
}
|
|
2296
|
+
return hop;
|
|
2297
|
+
}
|
|
1804
2298
|
async function executeHttp(config, args, opts = {}) {
|
|
1805
2299
|
const method = String(config.method ?? "GET").toUpperCase();
|
|
1806
2300
|
let url = config.url;
|
|
@@ -1821,7 +2315,7 @@ async function executeHttp(config, args, opts = {}) {
|
|
|
1821
2315
|
}
|
|
1822
2316
|
}
|
|
1823
2317
|
}
|
|
1824
|
-
|
|
2318
|
+
let requestInit = {
|
|
1825
2319
|
method,
|
|
1826
2320
|
url: url ?? "",
|
|
1827
2321
|
headers
|
|
@@ -1857,23 +2351,50 @@ async function executeHttp(config, args, opts = {}) {
|
|
|
1857
2351
|
}
|
|
1858
2352
|
}
|
|
1859
2353
|
const client = opts.client;
|
|
1860
|
-
const
|
|
2354
|
+
const allowPrivate = opts.allowPrivate ?? false;
|
|
2355
|
+
let resp;
|
|
2356
|
+
let hops = 0;
|
|
2357
|
+
for (; ; ) {
|
|
2358
|
+
await assertEgressAllowed(requestInit.url, { allowPrivate });
|
|
2359
|
+
resp = client ? await client.request(requestInit) : await defaultRequest(requestInit);
|
|
2360
|
+
const location = resp.headers.location ?? resp.headers.Location;
|
|
2361
|
+
if (!REDIRECT_STATUS.has(resp.status) || !location) break;
|
|
2362
|
+
if (hops >= MAX_REDIRECTS) {
|
|
2363
|
+
throw new EngineActionError(`too many redirects (more than ${MAX_REDIRECTS}) starting at ${url}`);
|
|
2364
|
+
}
|
|
2365
|
+
hops += 1;
|
|
2366
|
+
requestInit = nextHop(requestInit, resp.status, location);
|
|
2367
|
+
}
|
|
1861
2368
|
const contentType = resp.headers["content-type"] ?? resp.headers["Content-Type"] ?? "";
|
|
1862
|
-
let
|
|
2369
|
+
let text = "";
|
|
1863
2370
|
try {
|
|
1864
|
-
|
|
2371
|
+
text = await resp.text();
|
|
1865
2372
|
} catch {
|
|
1866
|
-
|
|
2373
|
+
text = "";
|
|
2374
|
+
}
|
|
2375
|
+
const bytes = Buffer.from(text, "utf8");
|
|
2376
|
+
const truncated = resp.truncated === true || bytes.byteLength > MAX_RESPONSE_BYTES;
|
|
2377
|
+
let data;
|
|
2378
|
+
if (truncated) {
|
|
2379
|
+
data = bytes.subarray(0, MAX_RESPONSE_BYTES).toString("utf8");
|
|
2380
|
+
} else {
|
|
2381
|
+
try {
|
|
2382
|
+
data = contentType.includes("application/json") ? await resp.json() : text;
|
|
2383
|
+
} catch {
|
|
2384
|
+
data = text;
|
|
2385
|
+
}
|
|
1867
2386
|
}
|
|
1868
2387
|
const safeHeaders = {};
|
|
1869
2388
|
for (const [k, v] of Object.entries(resp.headers)) {
|
|
1870
2389
|
if (!SENSITIVE_RESPONSE_HEADERS.has(k.toLowerCase())) safeHeaders[k] = v;
|
|
1871
2390
|
}
|
|
1872
|
-
|
|
2391
|
+
const result = {
|
|
1873
2392
|
status_code: resp.status,
|
|
1874
2393
|
headers: safeHeaders,
|
|
1875
2394
|
data
|
|
1876
2395
|
};
|
|
2396
|
+
if (truncated) result.truncated = true;
|
|
2397
|
+
return result;
|
|
1877
2398
|
}
|
|
1878
2399
|
|
|
1879
2400
|
// src/tools/registry.ts
|
|
@@ -2003,8 +2524,13 @@ var UPDATABLE_COLUMNS = /* @__PURE__ */ new Set([
|
|
|
2003
2524
|
"requires_approval",
|
|
2004
2525
|
"approvalPolicy",
|
|
2005
2526
|
"approval_policy",
|
|
2006
|
-
"enabled"
|
|
2527
|
+
"enabled",
|
|
2528
|
+
"metaData",
|
|
2529
|
+
"meta_data"
|
|
2007
2530
|
]);
|
|
2531
|
+
function allowPrivateEgress(engine) {
|
|
2532
|
+
return Boolean(engine?.config?.allowPrivateEgress);
|
|
2533
|
+
}
|
|
2008
2534
|
function toolVisible(ct, principals) {
|
|
2009
2535
|
return aclVisible(ct.acl, principals);
|
|
2010
2536
|
}
|
|
@@ -2086,11 +2612,19 @@ function redactToolResult(result, policy, opts) {
|
|
|
2086
2612
|
if (failed.length) note.rules_failed = failed;
|
|
2087
2613
|
return [redacted, note];
|
|
2088
2614
|
}
|
|
2089
|
-
function
|
|
2090
|
-
|
|
2091
|
-
if (
|
|
2092
|
-
|
|
2615
|
+
function cryptoKey(engine) {
|
|
2616
|
+
const key = getSecretKey(engine.config);
|
|
2617
|
+
if (key.length !== 32) {
|
|
2618
|
+
throw new Error(`CE_SECRET_KEY is malformed: decoded to ${key.length} bytes, need 32`);
|
|
2093
2619
|
}
|
|
2620
|
+
return key;
|
|
2621
|
+
}
|
|
2622
|
+
function decryptRowConfig(row, engine) {
|
|
2623
|
+
if (!row.config_encrypted) return {};
|
|
2624
|
+
return decryptDict(String(row.config_encrypted), cryptoKey(engine));
|
|
2625
|
+
}
|
|
2626
|
+
function rowToToolConfig(row, engine, config) {
|
|
2627
|
+
config ??= decryptRowConfig(row, engine);
|
|
2094
2628
|
return new ToolConfig({
|
|
2095
2629
|
id: String(row.id),
|
|
2096
2630
|
name: String(row.name),
|
|
@@ -2101,9 +2635,24 @@ function rowToToolConfig(row, engine) {
|
|
|
2101
2635
|
acl: row.acl != null ? [...row.acl] : null,
|
|
2102
2636
|
requiresApproval: Boolean(row.requires_approval),
|
|
2103
2637
|
approvalPolicy: row.approval_policy ?? {},
|
|
2104
|
-
enabled: Boolean(row.enabled)
|
|
2638
|
+
enabled: Boolean(row.enabled),
|
|
2639
|
+
metaData: row.meta_data ?? {}
|
|
2105
2640
|
});
|
|
2106
2641
|
}
|
|
2642
|
+
function rowToToolConfigLenient(row, engine) {
|
|
2643
|
+
if (!row.config_encrypted) return rowToToolConfig(row, engine, {});
|
|
2644
|
+
const key = cryptoKey(engine);
|
|
2645
|
+
let config;
|
|
2646
|
+
try {
|
|
2647
|
+
config = decryptDict(String(row.config_encrypted), key);
|
|
2648
|
+
} catch {
|
|
2649
|
+
console.warn(
|
|
2650
|
+
`tool ${String(row.id)} (${String(row.name)}): config_error \u2014 its stored config could not be decrypted (was the secret key rotated?); leaving it out of the tool set`
|
|
2651
|
+
);
|
|
2652
|
+
return null;
|
|
2653
|
+
}
|
|
2654
|
+
return rowToToolConfig(row, engine, config);
|
|
2655
|
+
}
|
|
2107
2656
|
async function loadPersistedCanonicals(engine, sourceId) {
|
|
2108
2657
|
const params = [];
|
|
2109
2658
|
let sql = `SELECT * FROM context_engine_tools WHERE enabled IS TRUE`;
|
|
@@ -2114,7 +2663,9 @@ async function loadPersistedCanonicals(engine, sourceId) {
|
|
|
2114
2663
|
const result = await engine.pool.query(sql, params);
|
|
2115
2664
|
const canonicals = [];
|
|
2116
2665
|
for (const row of result.rows) {
|
|
2117
|
-
|
|
2666
|
+
const tc = rowToToolConfigLenient(row, engine);
|
|
2667
|
+
if (tc === null) continue;
|
|
2668
|
+
canonicals.push(...canonicalFromConfig(tc));
|
|
2118
2669
|
}
|
|
2119
2670
|
return canonicals;
|
|
2120
2671
|
}
|
|
@@ -2126,13 +2677,18 @@ async function mergedTools(engine, sourceId) {
|
|
|
2126
2677
|
async function registerTool(engine, tc) {
|
|
2127
2678
|
canonicalFromConfig(tc);
|
|
2128
2679
|
const config = tc.config ?? {};
|
|
2129
|
-
|
|
2680
|
+
if (containsSentinel(config)) {
|
|
2681
|
+
throw new ConfigTemplateError(
|
|
2682
|
+
`config contains the ${JSON.stringify(REDACTED_SENTINEL)} placeholder \u2014 a redacted template cannot be registered as a new tool; re-enter the secret values`
|
|
2683
|
+
);
|
|
2684
|
+
}
|
|
2685
|
+
const configEncrypted = Object.keys(config).length ? encryptDict(config, cryptoKey(engine)) : null;
|
|
2130
2686
|
const id = crypto.randomUUID();
|
|
2131
2687
|
await engine.pool.query(
|
|
2132
2688
|
`INSERT INTO context_engine_tools
|
|
2133
2689
|
(id, name, kind, description, source_id, acl, config_encrypted,
|
|
2134
|
-
requires_approval, approval_policy, enabled)
|
|
2135
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10)`,
|
|
2690
|
+
requires_approval, approval_policy, enabled, meta_data)
|
|
2691
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11::jsonb)`,
|
|
2136
2692
|
[
|
|
2137
2693
|
id,
|
|
2138
2694
|
tc.name,
|
|
@@ -2143,7 +2699,8 @@ async function registerTool(engine, tc) {
|
|
|
2143
2699
|
configEncrypted,
|
|
2144
2700
|
tc.requiresApproval,
|
|
2145
2701
|
JSON.stringify(tc.approvalPolicy ?? {}),
|
|
2146
|
-
tc.enabled
|
|
2702
|
+
tc.enabled,
|
|
2703
|
+
JSON.stringify(tc.metaData ?? {})
|
|
2147
2704
|
]
|
|
2148
2705
|
);
|
|
2149
2706
|
return id;
|
|
@@ -2160,12 +2717,41 @@ async function updateTool(engine, id, opts) {
|
|
|
2160
2717
|
let i = 1;
|
|
2161
2718
|
for (const [key, value] of Object.entries(fields)) {
|
|
2162
2719
|
if (key === "config") {
|
|
2720
|
+
let config = value;
|
|
2721
|
+
if (config && containsSentinel(config)) {
|
|
2722
|
+
if (Object.hasOwn(fields, "kind") && fields.kind !== row.kind) {
|
|
2723
|
+
throw new ConfigTemplateError(
|
|
2724
|
+
"cannot change kind and keep redacted secrets in one PATCH \u2014 re-enter the config in full"
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
if (config.body_secret === false && containsSentinel(config.body)) {
|
|
2728
|
+
throw new ConfigTemplateError(
|
|
2729
|
+
`body_secret cannot be turned off while the body still contains ${JSON.stringify(REDACTED_SENTINEL)} \u2014 re-enter the body to declassify it`
|
|
2730
|
+
);
|
|
2731
|
+
}
|
|
2732
|
+
if (!row.config_encrypted) {
|
|
2733
|
+
throw new ConfigTemplateError(
|
|
2734
|
+
`config contains ${JSON.stringify(REDACTED_SENTINEL)} but this tool has no stored config to keep \u2014 re-enter the secret values`
|
|
2735
|
+
);
|
|
2736
|
+
}
|
|
2737
|
+
const key2 = cryptoKey(engine);
|
|
2738
|
+
let stored;
|
|
2739
|
+
try {
|
|
2740
|
+
stored = decryptDict(String(row.config_encrypted), key2);
|
|
2741
|
+
} catch (exc) {
|
|
2742
|
+
throw new ConfigTemplateError(
|
|
2743
|
+
`the stored config cannot be decrypted (was the secret key rotated?) \u2014 re-enter the config in full, without ${JSON.stringify(REDACTED_SENTINEL)} values`,
|
|
2744
|
+
{ cause: exc }
|
|
2745
|
+
);
|
|
2746
|
+
}
|
|
2747
|
+
config = mergeRedacted(config, stored);
|
|
2748
|
+
}
|
|
2163
2749
|
sets.push(`config_encrypted = $${i++}`);
|
|
2164
|
-
params.push(
|
|
2750
|
+
params.push(config && Object.keys(config).length ? encryptDict(config, cryptoKey(engine)) : null);
|
|
2165
2751
|
} else if (UPDATABLE_COLUMNS.has(key)) {
|
|
2166
|
-
const col = key === "sourceId" ? "source_id" : key === "requiresApproval" ? "requires_approval" : key === "approvalPolicy" ? "approval_policy" : key;
|
|
2752
|
+
const col = key === "sourceId" ? "source_id" : key === "requiresApproval" ? "requires_approval" : key === "approvalPolicy" ? "approval_policy" : key === "metaData" ? "meta_data" : key;
|
|
2167
2753
|
sets.push(`${col} = $${i++}`);
|
|
2168
|
-
params.push(col === "approval_policy" ? JSON.stringify(value ?? {}) : value);
|
|
2754
|
+
params.push(col === "approval_policy" || col === "meta_data" ? JSON.stringify(value ?? {}) : value);
|
|
2169
2755
|
}
|
|
2170
2756
|
}
|
|
2171
2757
|
sets.push(`updated_at = now()`);
|
|
@@ -2174,7 +2760,8 @@ async function updateTool(engine, id, opts) {
|
|
|
2174
2760
|
`UPDATE context_engine_tools SET ${sets.join(", ")} WHERE id = $${i} RETURNING *`,
|
|
2175
2761
|
params
|
|
2176
2762
|
);
|
|
2177
|
-
|
|
2763
|
+
const row_ = updated.rows[0];
|
|
2764
|
+
return rowToToolConfigLenient(row_, engine) ?? rowToToolConfig(row_, engine, {});
|
|
2178
2765
|
}
|
|
2179
2766
|
async function deleteTool(engine, id, opts = {}) {
|
|
2180
2767
|
const existing = await engine.pool.query(`SELECT * FROM context_engine_tools WHERE id = $1`, [id]);
|
|
@@ -2241,17 +2828,38 @@ function probeFailure(exc, kind) {
|
|
|
2241
2828
|
console.warn(`test_tool(kind=${kind}) failed:`, exc, "->", category);
|
|
2242
2829
|
return { ok: false, error: category };
|
|
2243
2830
|
}
|
|
2244
|
-
async function testTool(
|
|
2831
|
+
async function testTool(engine, tc) {
|
|
2245
2832
|
const kind = tc.kind;
|
|
2246
2833
|
const config = tc.config ?? {};
|
|
2834
|
+
if (containsSentinel(config)) {
|
|
2835
|
+
throw new ConfigTemplateError(
|
|
2836
|
+
`config contains the ${JSON.stringify(REDACTED_SENTINEL)} placeholder \u2014 a probe would send it verbatim as the credential; re-enter the secret values`
|
|
2837
|
+
);
|
|
2838
|
+
}
|
|
2247
2839
|
if (kind === "http") {
|
|
2248
2840
|
const url = config.url;
|
|
2249
2841
|
if (!url) return { ok: false, error: "http config missing 'url'" };
|
|
2250
2842
|
try {
|
|
2251
|
-
|
|
2843
|
+
await assertEgressAllowed(url, { allowPrivate: allowPrivateEgress(engine) });
|
|
2844
|
+
} catch (exc) {
|
|
2845
|
+
if (exc instanceof EgressDenied) {
|
|
2846
|
+
console.warn(`testTool(kind=http) refused: ${exc.message}`);
|
|
2847
|
+
return { ok: false, error: "egress_denied" };
|
|
2848
|
+
}
|
|
2849
|
+
throw exc;
|
|
2850
|
+
}
|
|
2851
|
+
const client = engine?._toolHttpClient ?? null;
|
|
2852
|
+
try {
|
|
2853
|
+
const resp = client ? await client.request({
|
|
2854
|
+
method: "HEAD",
|
|
2855
|
+
url,
|
|
2856
|
+
headers: config.headers ?? {}
|
|
2857
|
+
}) : await fetch(url, {
|
|
2252
2858
|
method: "HEAD",
|
|
2253
2859
|
headers: config.headers ?? {},
|
|
2254
|
-
|
|
2860
|
+
// No automatic follow: a redirect would reach a destination the
|
|
2861
|
+
// check above never saw.
|
|
2862
|
+
redirect: "manual",
|
|
2255
2863
|
signal: AbortSignal.timeout(1e4)
|
|
2256
2864
|
});
|
|
2257
2865
|
return { ok: resp.status < 500, status_code: resp.status };
|
|
@@ -2272,12 +2880,16 @@ async function testTool(_engine, tc) {
|
|
|
2272
2880
|
const url = config.url;
|
|
2273
2881
|
if (!url) return { ok: false, error: "mcp config missing 'url'" };
|
|
2274
2882
|
const token = config.oauth_token ?? config.bearer ?? config.access_token;
|
|
2275
|
-
const mcp = new PromptevMCP();
|
|
2883
|
+
const mcp = new PromptevMCP({ allowPrivate: allowPrivateEgress(engine) });
|
|
2276
2884
|
try {
|
|
2277
2885
|
await mcp.addServer(tc.name, url, { token: token ?? null });
|
|
2278
2886
|
const client = mcp.clients.get(tc.name);
|
|
2279
2887
|
return { ok: true, tools: client.tools.map((t) => t.name) };
|
|
2280
2888
|
} catch (exc) {
|
|
2889
|
+
if (exc instanceof EgressDenied) {
|
|
2890
|
+
console.warn(`testTool(kind=mcp) refused: ${exc.message}`);
|
|
2891
|
+
return { ok: false, error: "egress_denied" };
|
|
2892
|
+
}
|
|
2281
2893
|
return probeFailure(exc, "mcp");
|
|
2282
2894
|
} finally {
|
|
2283
2895
|
await mcp.shutdown();
|
|
@@ -2285,11 +2897,20 @@ async function testTool(_engine, tc) {
|
|
|
2285
2897
|
}
|
|
2286
2898
|
return { ok: false, error: `test_tool does not support kind=${JSON.stringify(kind)}` };
|
|
2287
2899
|
}
|
|
2288
|
-
async function findAndClaimApproved(engine, toolName, args, sourceId) {
|
|
2900
|
+
async function findAndClaimApproved(engine, toolName, args, sourceId, principals, approvalScope) {
|
|
2289
2901
|
const claimedAt = /* @__PURE__ */ new Date();
|
|
2902
|
+
const params = [toolName];
|
|
2903
|
+
let scopeSql = "approval_scope IS NULL";
|
|
2904
|
+
if (approvalScope !== null) {
|
|
2905
|
+
params.push(approvalScope);
|
|
2906
|
+
scopeSql = `approval_scope = $${params.length}`;
|
|
2907
|
+
}
|
|
2908
|
+
const wall = claimVisibilitySql(principals, params.length + 1);
|
|
2909
|
+
params.push(...wall.params);
|
|
2290
2910
|
const candidates = await engine.pool.query(
|
|
2291
|
-
`SELECT * FROM context_engine_tool_approvals
|
|
2292
|
-
|
|
2911
|
+
`SELECT * FROM context_engine_tool_approvals
|
|
2912
|
+
WHERE tool_name = $1 AND status = 'approved' AND ${scopeSql} AND ${wall.sql}`,
|
|
2913
|
+
params
|
|
2293
2914
|
);
|
|
2294
2915
|
for (const row of candidates.rows) {
|
|
2295
2916
|
if ((row.source_id ?? null) !== (sourceId ?? null)) continue;
|
|
@@ -2306,7 +2927,7 @@ async function findAndClaimApproved(engine, toolName, args, sourceId) {
|
|
|
2306
2927
|
}
|
|
2307
2928
|
return null;
|
|
2308
2929
|
}
|
|
2309
|
-
async function dispatchMcp(ct, config, args) {
|
|
2930
|
+
async function dispatchMcp(ct, config, args, opts = {}) {
|
|
2310
2931
|
const url = config.url;
|
|
2311
2932
|
if (!url) throw new EngineActionError(`mcp tool ${ct.callName} config missing 'url'`);
|
|
2312
2933
|
const headers = { ...config.headers ?? {} };
|
|
@@ -2315,7 +2936,7 @@ async function dispatchMcp(ct, config, args) {
|
|
|
2315
2936
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
2316
2937
|
else if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
2317
2938
|
const toolName = String(ct.raw?.tool_name ?? ct.displayName);
|
|
2318
|
-
const mcp = new PromptevMCP();
|
|
2939
|
+
const mcp = new PromptevMCP({ allowPrivate: opts.allowPrivate ?? false });
|
|
2319
2940
|
try {
|
|
2320
2941
|
await mcp.addServer(ct.callName, url, { headers: Object.keys(headers).length ? headers : null });
|
|
2321
2942
|
const client = mcp.clients.get(ct.callName);
|
|
@@ -2326,7 +2947,10 @@ async function dispatchMcp(ct, config, args) {
|
|
|
2326
2947
|
}
|
|
2327
2948
|
async function dispatch(engine, ct, config, args) {
|
|
2328
2949
|
if (ct.kind === "http") {
|
|
2329
|
-
return executeHttp(config, args, {
|
|
2950
|
+
return executeHttp(config, args, {
|
|
2951
|
+
client: engine._toolHttpClient ?? null,
|
|
2952
|
+
allowPrivate: allowPrivateEgress(engine)
|
|
2953
|
+
});
|
|
2330
2954
|
}
|
|
2331
2955
|
if (ct.kind === "db") {
|
|
2332
2956
|
const query = String(args.query ?? "");
|
|
@@ -2341,7 +2965,9 @@ async function dispatch(engine, ct, config, args) {
|
|
|
2341
2965
|
}
|
|
2342
2966
|
return result;
|
|
2343
2967
|
}
|
|
2344
|
-
if (ct.kind === "mcp")
|
|
2968
|
+
if (ct.kind === "mcp") {
|
|
2969
|
+
return dispatchMcp(ct, config, args, { allowPrivate: allowPrivateEgress(engine) });
|
|
2970
|
+
}
|
|
2345
2971
|
if (ct.kind === "function") {
|
|
2346
2972
|
const fn = ct.raw?.callable;
|
|
2347
2973
|
if (!fn) throw new EngineActionError(`function tool ${ct.callName} has no callable`);
|
|
@@ -2362,10 +2988,20 @@ async function decryptCtConfig(engine, toolId) {
|
|
|
2362
2988
|
]);
|
|
2363
2989
|
const row = result.rows[0];
|
|
2364
2990
|
if (!row?.config_encrypted) return {};
|
|
2365
|
-
return decryptDict(String(row.config_encrypted),
|
|
2991
|
+
return decryptDict(String(row.config_encrypted), cryptoKey(engine));
|
|
2992
|
+
}
|
|
2993
|
+
var warnedUnscoped = /* @__PURE__ */ new Set();
|
|
2994
|
+
function warnUnscopedApproval(callName) {
|
|
2995
|
+
if (warnedUnscoped.has(callName)) return;
|
|
2996
|
+
warnedUnscoped.add(callName);
|
|
2997
|
+
process.emitWarning(
|
|
2998
|
+
`executeTool(${JSON.stringify(callName)}) needs an approval but was called without approvalScope. The record is opened and matched UNSCOPED (tool + args + sourceId, behind the principals wall). Pass approvalScope: <opaque string> (e.g. a run id) so approvals are claimable only within that scope; a gated call without one will be refused in the next release.`,
|
|
2999
|
+
{ type: "DeprecationWarning", code: "CE_APPROVAL_SCOPE_MISSING" }
|
|
3000
|
+
);
|
|
2366
3001
|
}
|
|
2367
3002
|
async function executeTool(engine, callName, args, opts = {}) {
|
|
2368
3003
|
const runtimeArgs = args ?? {};
|
|
3004
|
+
const approvalScope = validateApprovalScope(opts.approvalScope);
|
|
2369
3005
|
const ct = findTool(await mergedTools(engine, opts.sourceId ?? null), callName);
|
|
2370
3006
|
if (!ct) throw new EngineActionError(`tool not found: ${callName}`);
|
|
2371
3007
|
if (!toolVisible(ct, opts.principals ?? null)) {
|
|
@@ -2374,22 +3010,32 @@ async function executeTool(engine, callName, args, opts = {}) {
|
|
|
2374
3010
|
const publicArgs = stripUnderscoreArgs(runtimeArgs);
|
|
2375
3011
|
let approvalId = null;
|
|
2376
3012
|
if (shouldRequireApproval(ct, publicArgs)) {
|
|
2377
|
-
|
|
3013
|
+
if (approvalScope === null) warnUnscopedApproval(callName);
|
|
3014
|
+
approvalId = await findAndClaimApproved(
|
|
3015
|
+
engine,
|
|
3016
|
+
ct.callName,
|
|
3017
|
+
publicArgs,
|
|
3018
|
+
opts.sourceId ?? null,
|
|
3019
|
+
opts.principals ?? null,
|
|
3020
|
+
approvalScope
|
|
3021
|
+
);
|
|
2378
3022
|
if (approvalId == null) {
|
|
2379
|
-
const
|
|
3023
|
+
const pendingOpts = {
|
|
2380
3024
|
toolName: ct.callName,
|
|
2381
3025
|
args: publicArgs,
|
|
2382
3026
|
sourceId: opts.sourceId ?? null,
|
|
2383
3027
|
principals: opts.principals ?? null,
|
|
2384
3028
|
policy: ct.approvalPolicy ?? {}
|
|
2385
|
-
}
|
|
3029
|
+
};
|
|
3030
|
+
const record = approvalScope === null ? await createPending(engine, pendingOpts) : await findOrCreatePending(engine, { ...pendingOpts, approvalScope });
|
|
2386
3031
|
const reason = ct.requiresApproval ? "tool requires approval" : `approval policy condition met: ${ct.approvalPolicy?.condition}`;
|
|
2387
3032
|
return {
|
|
2388
3033
|
approval_required: {
|
|
2389
3034
|
approval_id: String(record.id),
|
|
2390
3035
|
tool_name: ct.callName,
|
|
2391
3036
|
args: publicArgs,
|
|
2392
|
-
reason
|
|
3037
|
+
reason,
|
|
3038
|
+
expires_at: record.expiresAt ? record.expiresAt.toISOString() : null
|
|
2393
3039
|
}
|
|
2394
3040
|
};
|
|
2395
3041
|
}
|
|
@@ -2414,8 +3060,9 @@ async function executeTool(engine, callName, args, opts = {}) {
|
|
|
2414
3060
|
let truncated = false;
|
|
2415
3061
|
if (success) {
|
|
2416
3062
|
try {
|
|
3063
|
+
const redactPrincipals = opts.principals === TRUSTED ? null : opts.principals ?? null;
|
|
2417
3064
|
const [redacted] = redactToolResult(rawResult, engine.config.redaction, {
|
|
2418
|
-
principals:
|
|
3065
|
+
principals: redactPrincipals,
|
|
2419
3066
|
secretKey: engine.config.secretKey,
|
|
2420
3067
|
hooks: engine.hooks
|
|
2421
3068
|
});
|
|
@@ -2460,6 +3107,9 @@ var DISCOVERY_TIMEOUT_MS = 1e4;
|
|
|
2460
3107
|
var TOKEN_TIMEOUT_MS = 15e3;
|
|
2461
3108
|
var PROBE_TIMEOUT_MS = 5e3;
|
|
2462
3109
|
var REGISTER_TIMEOUT_MS = 1e4;
|
|
3110
|
+
async function checkEgress2(url, allowPrivate) {
|
|
3111
|
+
await assertEgressAllowed(url, { allowPrivate });
|
|
3112
|
+
}
|
|
2463
3113
|
function report(hooks, exc, ctx) {
|
|
2464
3114
|
if (hooks) emitError(hooks, exc, ctx);
|
|
2465
3115
|
else console.error("[context-engine] mcp_oauth error:", exc, "| ctx=", ctx);
|
|
@@ -2490,11 +3140,16 @@ async function fetchJson(url, opts = {}) {
|
|
|
2490
3140
|
method: opts.method ?? "GET",
|
|
2491
3141
|
headers: opts.headers,
|
|
2492
3142
|
body: opts.body,
|
|
2493
|
-
|
|
3143
|
+
// Never followed: a redirect reaches a destination no egress check saw,
|
|
3144
|
+
// and on the token endpoint it would forward the client secret with it.
|
|
3145
|
+
redirect: opts.redirect ?? "manual",
|
|
2494
3146
|
signal: AbortSignal.timeout(opts.timeout ?? DISCOVERY_TIMEOUT_MS)
|
|
2495
3147
|
});
|
|
2496
3148
|
return {
|
|
3149
|
+
// `type` rides along so callers can tell an opaque redirect (status 0)
|
|
3150
|
+
// from a request that simply failed — see `isRedirect`.
|
|
2497
3151
|
status: resp.status,
|
|
3152
|
+
type: resp.type,
|
|
2498
3153
|
json: () => resp.json(),
|
|
2499
3154
|
text: () => resp.text()
|
|
2500
3155
|
};
|
|
@@ -2502,6 +3157,7 @@ async function fetchJson(url, opts = {}) {
|
|
|
2502
3157
|
async function discoverOauthMetadata(serverUrl, opts = {}) {
|
|
2503
3158
|
const parsed = new URL(serverUrl);
|
|
2504
3159
|
const base = `${parsed.protocol}//${parsed.host}`;
|
|
3160
|
+
await checkEgress2(base, opts.allowPrivate ?? false);
|
|
2505
3161
|
const wellKnownUrl = `${base}/.well-known/oauth-authorization-server`;
|
|
2506
3162
|
try {
|
|
2507
3163
|
const resp = await fetchJson(wellKnownUrl, { timeout: DISCOVERY_TIMEOUT_MS });
|
|
@@ -2552,7 +3208,7 @@ async function discoverOauthMetadata(serverUrl, opts = {}) {
|
|
|
2552
3208
|
timeout: PROBE_TIMEOUT_MS,
|
|
2553
3209
|
redirect: "manual"
|
|
2554
3210
|
});
|
|
2555
|
-
if (
|
|
3211
|
+
if (resp.status === 200 || resp.status === 400 || resp.status === 302 || resp.status === 303 || resp.type === "opaqueredirect") {
|
|
2556
3212
|
return {
|
|
2557
3213
|
authorization_endpoint: `${base}${authPath}`,
|
|
2558
3214
|
token_endpoint: `${base}${tokenPath}`,
|
|
@@ -2567,6 +3223,7 @@ async function discoverOauthMetadata(serverUrl, opts = {}) {
|
|
|
2567
3223
|
return null;
|
|
2568
3224
|
}
|
|
2569
3225
|
async function registerOauthClient(registrationEndpoint, redirectUri, clientName = "Promptev Context Engine", opts = {}) {
|
|
3226
|
+
await checkEgress2(registrationEndpoint, opts.allowPrivate ?? false);
|
|
2570
3227
|
const payload = {
|
|
2571
3228
|
client_name: clientName,
|
|
2572
3229
|
redirect_uris: [redirectUri],
|
|
@@ -2637,6 +3294,7 @@ async function exchangeCodeForToken(state, code, store, opts = {}) {
|
|
|
2637
3294
|
if (!pending) throw new Error("Invalid or expired OAuth state \u2014 please try connecting again");
|
|
2638
3295
|
const tokenEndpoint = pending.token_endpoint;
|
|
2639
3296
|
if (!tokenEndpoint) throw new Error("No token_endpoint in stored OAuth state");
|
|
3297
|
+
await checkEgress2(tokenEndpoint, opts.allowPrivate ?? false);
|
|
2640
3298
|
const body = new URLSearchParams({
|
|
2641
3299
|
grant_type: "authorization_code",
|
|
2642
3300
|
code,
|
|
@@ -2700,8 +3358,8 @@ function coerceUuid(value) {
|
|
|
2700
3358
|
}
|
|
2701
3359
|
return value;
|
|
2702
3360
|
}
|
|
2703
|
-
function toolRowPublic(row) {
|
|
2704
|
-
|
|
3361
|
+
function toolRowPublic(row, opts = {}) {
|
|
3362
|
+
const out = {
|
|
2705
3363
|
id: String(row.id),
|
|
2706
3364
|
name: row.name,
|
|
2707
3365
|
kind: row.kind,
|
|
@@ -2710,8 +3368,36 @@ function toolRowPublic(row) {
|
|
|
2710
3368
|
acl: row.acl != null ? [...row.acl] : null,
|
|
2711
3369
|
requires_approval: row.requires_approval,
|
|
2712
3370
|
approval_policy: row.approval_policy ?? {},
|
|
2713
|
-
enabled: row.enabled
|
|
3371
|
+
enabled: row.enabled,
|
|
3372
|
+
// Engine-opaque user metadata — admin surface only (the agent-facing
|
|
3373
|
+
// listTools/searchTools views never carry it), returned in clear:
|
|
3374
|
+
// secrets belong in `config`.
|
|
3375
|
+
meta_data: row.meta_data ?? {}
|
|
2714
3376
|
};
|
|
3377
|
+
if (opts.template && row.config_encrypted) {
|
|
3378
|
+
try {
|
|
3379
|
+
if (!opts.templateKey) throw new Error("no secret key configured");
|
|
3380
|
+
out.config_redacted = redactConfig(
|
|
3381
|
+
String(row.kind),
|
|
3382
|
+
decryptDict(String(row.config_encrypted), opts.templateKey)
|
|
3383
|
+
);
|
|
3384
|
+
} catch {
|
|
3385
|
+
out.config_error = "undecryptable";
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3388
|
+
return out;
|
|
3389
|
+
}
|
|
3390
|
+
function escapeHtml(text) {
|
|
3391
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3392
|
+
}
|
|
3393
|
+
function authorizeToolAcl(requested, principals) {
|
|
3394
|
+
if (requested == null && principals !== TRUSTED) {
|
|
3395
|
+
throw new HandlerError(
|
|
3396
|
+
403,
|
|
3397
|
+
"a tool without an acl is visible to every principal; only a trusted mount may register one \u2014 pass acl with principals you hold"
|
|
3398
|
+
);
|
|
3399
|
+
}
|
|
3400
|
+
authorizeAcl(requested, principals);
|
|
2715
3401
|
}
|
|
2716
3402
|
function resolveCaller(value, surface) {
|
|
2717
3403
|
return resolveRequestPrincipals(value, { surface });
|
|
@@ -2737,10 +3423,27 @@ function popupCloseHtml(success, message, targetOrigin, tokenData = null) {
|
|
|
2737
3423
|
if (tokenData && success) payload.token = tokenData;
|
|
2738
3424
|
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
|
2739
3425
|
const title = success ? "Connected" : "Failed";
|
|
2740
|
-
return `<!DOCTYPE html><html><head><title>Context Engine MCP OAuth</title></head><body style="font-family:system-ui;text-align:center;padding:40px;"><h2>${title}</h2><p>${message}</p><p style="font-size:12px;">This window will close automatically…</p><script>try { var d = JSON.parse(atob("${encoded}")); if (window.opener) window.opener.postMessage(d, "${targetOrigin}");} catch (e) { console.error('postMessage error', e); }setTimeout(function () { window.close(); }, 1500);</script></body></html>`;
|
|
3426
|
+
return `<!DOCTYPE html><html><head><title>Context Engine MCP OAuth</title></head><body style="font-family:system-ui;text-align:center;padding:40px;"><h2>${title}</h2><p>${escapeHtml(message)}</p><p style="font-size:12px;">This window will close automatically…</p><script>try { var d = JSON.parse(atob("${encoded}")); if (window.opener) window.opener.postMessage(d, "${targetOrigin}");} catch (e) { console.error('postMessage error', e); }setTimeout(function () { window.close(); }, 1500);</script></body></html>`;
|
|
2741
3427
|
}
|
|
2742
3428
|
function createToolsHandlers(engine, opts = {}) {
|
|
2743
3429
|
const store = opts.pendingStore ?? new InMemoryPendingStore();
|
|
3430
|
+
const configTemplate = opts.configTemplate ?? false;
|
|
3431
|
+
const templateKey = () => {
|
|
3432
|
+
if (!configTemplate) return null;
|
|
3433
|
+
let key;
|
|
3434
|
+
try {
|
|
3435
|
+
key = getSecretKey(engine.config);
|
|
3436
|
+
} catch {
|
|
3437
|
+
return null;
|
|
3438
|
+
}
|
|
3439
|
+
if (key.length !== 32) {
|
|
3440
|
+
console.warn(
|
|
3441
|
+
`CE_SECRET_KEY is malformed: decoded to ${key.length} bytes, need 32; tool config templates will degrade to config_error`
|
|
3442
|
+
);
|
|
3443
|
+
return null;
|
|
3444
|
+
}
|
|
3445
|
+
return key;
|
|
3446
|
+
};
|
|
2744
3447
|
const redirectBaseUrl = (opts.redirectBaseUrl ?? "").replace(/\/$/, "");
|
|
2745
3448
|
let targetOrigin = "";
|
|
2746
3449
|
try {
|
|
@@ -2753,11 +3456,12 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2753
3456
|
async createTool(body, principals) {
|
|
2754
3457
|
const caller = resolveCaller(principals, "POST /tools");
|
|
2755
3458
|
const tc = ToolConfig.fromUnknown(body);
|
|
2756
|
-
|
|
3459
|
+
authorizeToolAcl(tc.acl, caller);
|
|
2757
3460
|
try {
|
|
2758
3461
|
const toolId = engine.registerTool ? await engine.registerTool(tc) : await registerTool(engine, tc);
|
|
2759
3462
|
return { id: toolId };
|
|
2760
3463
|
} catch (exc) {
|
|
3464
|
+
if (exc instanceof ConfigTemplateError) throw new HandlerError(400, exc.message);
|
|
2761
3465
|
if (exc instanceof Error && /canonicalFromConfig|kind=/.test(exc.message)) {
|
|
2762
3466
|
throw new HandlerError(400, String(exc));
|
|
2763
3467
|
}
|
|
@@ -2773,7 +3477,8 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2773
3477
|
sql += ` WHERE source_id = $1`;
|
|
2774
3478
|
}
|
|
2775
3479
|
const result = await engine.pool.query(sql, params);
|
|
2776
|
-
const
|
|
3480
|
+
const key = templateKey();
|
|
3481
|
+
const tools = result.rows.filter((row) => aclVisible(row.acl != null ? [...row.acl] : null, caller)).map((row) => toolRowPublic(row, { template: configTemplate, templateKey: key }));
|
|
2777
3482
|
return { tools };
|
|
2778
3483
|
},
|
|
2779
3484
|
async getTool(toolId, principals) {
|
|
@@ -2784,7 +3489,7 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2784
3489
|
if (!row || !aclVisible(row.acl != null ? [...row.acl] : null, caller)) {
|
|
2785
3490
|
throw new HandlerError(404, `tool not found: ${toolId}`);
|
|
2786
3491
|
}
|
|
2787
|
-
return toolRowPublic(row);
|
|
3492
|
+
return toolRowPublic(row, { template: configTemplate, templateKey: templateKey() });
|
|
2788
3493
|
},
|
|
2789
3494
|
async updateToolRoute(toolId, body, principals) {
|
|
2790
3495
|
const caller = resolveCaller(principals, "PATCH /tools/{tool_id}");
|
|
@@ -2799,12 +3504,31 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2799
3504
|
acl: "acl",
|
|
2800
3505
|
requires_approval: "requiresApproval",
|
|
2801
3506
|
approval_policy: "approvalPolicy",
|
|
2802
|
-
enabled: "enabled"
|
|
3507
|
+
enabled: "enabled",
|
|
3508
|
+
meta_data: "metaData"
|
|
2803
3509
|
};
|
|
2804
3510
|
for (const [k, dest] of Object.entries(map)) {
|
|
2805
3511
|
if (Object.hasOwn(body, k)) fields[dest] = body[k];
|
|
2806
3512
|
}
|
|
2807
|
-
|
|
3513
|
+
const wireName = {
|
|
3514
|
+
requiresApproval: "requires_approval",
|
|
3515
|
+
approvalPolicy: "approval_policy",
|
|
3516
|
+
metaData: "meta_data"
|
|
3517
|
+
};
|
|
3518
|
+
for (const field of [
|
|
3519
|
+
"name",
|
|
3520
|
+
"kind",
|
|
3521
|
+
"description",
|
|
3522
|
+
"requiresApproval",
|
|
3523
|
+
"approvalPolicy",
|
|
3524
|
+
"enabled",
|
|
3525
|
+
"metaData"
|
|
3526
|
+
]) {
|
|
3527
|
+
if (field in fields && fields[field] == null) {
|
|
3528
|
+
throw new HandlerError(400, `${wireName[field] ?? field} cannot be null`);
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
if ("acl" in fields) authorizeToolAcl(fields.acl, caller);
|
|
2808
3532
|
try {
|
|
2809
3533
|
const updated = engine.updateTool ? await engine.updateTool(toolId, { principals: caller, ...fields }) : await updateTool(engine, toolId, { principals: caller, ...fields });
|
|
2810
3534
|
return {
|
|
@@ -2816,9 +3540,11 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2816
3540
|
acl: updated.acl,
|
|
2817
3541
|
requires_approval: updated.requiresApproval,
|
|
2818
3542
|
approval_policy: updated.approvalPolicy,
|
|
2819
|
-
enabled: updated.enabled
|
|
3543
|
+
enabled: updated.enabled,
|
|
3544
|
+
meta_data: updated.metaData
|
|
2820
3545
|
};
|
|
2821
3546
|
} catch (exc) {
|
|
3547
|
+
if (exc instanceof ConfigTemplateError) throw new HandlerError(400, exc.message);
|
|
2822
3548
|
if (exc instanceof EngineActionError) throw new HandlerError(404, String(exc.message));
|
|
2823
3549
|
throw exc;
|
|
2824
3550
|
}
|
|
@@ -2837,7 +3563,12 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2837
3563
|
},
|
|
2838
3564
|
async testToolRoute(body) {
|
|
2839
3565
|
const tc = ToolConfig.fromUnknown(body);
|
|
2840
|
-
|
|
3566
|
+
try {
|
|
3567
|
+
return await (engine.testTool ? engine.testTool(tc) : testTool(engine, tc));
|
|
3568
|
+
} catch (exc) {
|
|
3569
|
+
if (exc instanceof ConfigTemplateError) throw new HandlerError(400, exc.message);
|
|
3570
|
+
throw exc;
|
|
3571
|
+
}
|
|
2841
3572
|
},
|
|
2842
3573
|
toolSchemaRoute(kind) {
|
|
2843
3574
|
try {
|
|
@@ -2846,14 +3577,30 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2846
3577
|
throw new HandlerError(404, `unknown tool kind: ${JSON.stringify(kind)}`);
|
|
2847
3578
|
}
|
|
2848
3579
|
},
|
|
2849
|
-
|
|
3580
|
+
/**
|
|
3581
|
+
* `approvalScope` is resolved by the adapter's `approvalScope` option —
|
|
3582
|
+
* SERVER-SIDE, never from the body. A body key of the same name is
|
|
3583
|
+
* ignored exactly like a body `principals`: a client that could name
|
|
3584
|
+
* its own scope could claim another scope's approvals. Absent, the
|
|
3585
|
+
* execution runs unscoped (the deprecated legacy path). A malformed
|
|
3586
|
+
* scope is the host's wiring bug, so it surfaces as a 500, never a 4xx
|
|
3587
|
+
* the client might "fix" by changing its body.
|
|
3588
|
+
*/
|
|
3589
|
+
async executeToolRoute(body, principals, approvalScope = null) {
|
|
2850
3590
|
const caller = resolveCaller(principals, "POST /tools/execute");
|
|
2851
3591
|
if (!body.call_name) throw new HandlerError(422, "call_name is required");
|
|
3592
|
+
let scope;
|
|
3593
|
+
try {
|
|
3594
|
+
scope = validateApprovalScope(approvalScope);
|
|
3595
|
+
} catch (exc) {
|
|
3596
|
+
throw new HandlerError(500, `approvalScope resolver: ${exc.message}`);
|
|
3597
|
+
}
|
|
2852
3598
|
try {
|
|
2853
3599
|
const exec = engine.executeTool ?? ((n, a, o) => executeTool(engine, n, a, o));
|
|
2854
3600
|
return await exec(body.call_name, body.args ?? null, {
|
|
2855
3601
|
sourceId: body.source_id,
|
|
2856
|
-
principals: caller
|
|
3602
|
+
principals: caller,
|
|
3603
|
+
approvalScope: scope
|
|
2857
3604
|
});
|
|
2858
3605
|
} catch (exc) {
|
|
2859
3606
|
if (exc instanceof EngineActionError) throw new HandlerError(404, String(exc.message));
|
|
@@ -2861,7 +3608,7 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2861
3608
|
}
|
|
2862
3609
|
},
|
|
2863
3610
|
async mcpConnectAndList(body) {
|
|
2864
|
-
const mcp = new PromptevMCP();
|
|
3611
|
+
const mcp = new PromptevMCP({ allowPrivate: allowPrivateEgress(engine) });
|
|
2865
3612
|
try {
|
|
2866
3613
|
await mcp.addServer(body.server_name, body.url, { token: body.token, headers: body.headers });
|
|
2867
3614
|
const client = mcp.clients.get(body.server_name);
|
|
@@ -2876,10 +3623,20 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2876
3623
|
requires_oauth: false
|
|
2877
3624
|
};
|
|
2878
3625
|
} catch (exc) {
|
|
3626
|
+
if (exc instanceof EgressDenied) throw new HandlerError(400, String(exc.message));
|
|
2879
3627
|
const msg = String(exc);
|
|
2880
3628
|
const statusMatch = /HTTP (401|403)/.exec(msg);
|
|
2881
3629
|
if (statusMatch || /401|403/.test(msg)) {
|
|
2882
|
-
|
|
3630
|
+
let metadata;
|
|
3631
|
+
try {
|
|
3632
|
+
metadata = await discoverOauthMetadata(body.url, {
|
|
3633
|
+
hooks: engine.hooks,
|
|
3634
|
+
allowPrivate: allowPrivateEgress(engine)
|
|
3635
|
+
});
|
|
3636
|
+
} catch (denied) {
|
|
3637
|
+
if (denied instanceof EgressDenied) throw new HandlerError(400, String(denied.message));
|
|
3638
|
+
throw denied;
|
|
3639
|
+
}
|
|
2883
3640
|
if (metadata) {
|
|
2884
3641
|
return {
|
|
2885
3642
|
server: body.server_name,
|
|
@@ -2905,7 +3662,16 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2905
3662
|
if (!redirectUri) {
|
|
2906
3663
|
throw new HandlerError(500, "redirectBaseUrl is required to start MCP OAuth");
|
|
2907
3664
|
}
|
|
2908
|
-
|
|
3665
|
+
let metadata;
|
|
3666
|
+
try {
|
|
3667
|
+
metadata = await discoverOauthMetadata(body.server_url, {
|
|
3668
|
+
hooks: engine.hooks,
|
|
3669
|
+
allowPrivate: allowPrivateEgress(engine)
|
|
3670
|
+
});
|
|
3671
|
+
} catch (exc) {
|
|
3672
|
+
if (exc instanceof EgressDenied) throw new HandlerError(400, String(exc.message));
|
|
3673
|
+
throw exc;
|
|
3674
|
+
}
|
|
2909
3675
|
if (!metadata) {
|
|
2910
3676
|
throw new HandlerError(502, "MCP server does not support OAuth \u2014 no authorization endpoint found");
|
|
2911
3677
|
}
|
|
@@ -2917,11 +3683,12 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2917
3683
|
String(metadata.registration_endpoint),
|
|
2918
3684
|
redirectUri,
|
|
2919
3685
|
"Promptev Context Engine",
|
|
2920
|
-
{ hooks: engine.hooks }
|
|
3686
|
+
{ hooks: engine.hooks, allowPrivate: allowPrivateEgress(engine) }
|
|
2921
3687
|
);
|
|
2922
3688
|
clientId = reg.client_id ?? null;
|
|
2923
3689
|
clientSecret = reg.client_secret || clientSecret;
|
|
2924
|
-
} catch {
|
|
3690
|
+
} catch (exc) {
|
|
3691
|
+
if (exc instanceof EgressDenied) throw new HandlerError(400, String(exc.message));
|
|
2925
3692
|
}
|
|
2926
3693
|
}
|
|
2927
3694
|
try {
|
|
@@ -2944,7 +3711,8 @@ function createToolsHandlers(engine, opts = {}) {
|
|
|
2944
3711
|
}
|
|
2945
3712
|
try {
|
|
2946
3713
|
const tokenResult = await exchangeCodeForToken(query.state, query.code, store, {
|
|
2947
|
-
hooks: engine.hooks
|
|
3714
|
+
hooks: engine.hooks,
|
|
3715
|
+
allowPrivate: allowPrivateEgress(engine)
|
|
2948
3716
|
});
|
|
2949
3717
|
return {
|
|
2950
3718
|
html: html(true, "Connected successfully", {
|
|
@@ -3123,7 +3891,11 @@ function createFastifyPlugin(engine, opts) {
|
|
|
3123
3891
|
fastify.post(
|
|
3124
3892
|
"/tools/execute",
|
|
3125
3893
|
wrap(
|
|
3126
|
-
async (req) => tools.executeToolRoute(
|
|
3894
|
+
async (req) => tools.executeToolRoute(
|
|
3895
|
+
req.body,
|
|
3896
|
+
await opts.principals(req),
|
|
3897
|
+
opts.approvalScope ? await opts.approvalScope(req) : null
|
|
3898
|
+
)
|
|
3127
3899
|
)
|
|
3128
3900
|
);
|
|
3129
3901
|
fastify.get(
|