@suveren/gateway 0.7.2 → 0.7.4
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 +10 -1
- package/content/integrations/gmail.json +3 -6
- package/dist/control-plane/index.mjs +111 -10
- package/dist/mcp-server/http.mjs +59 -8
- package/dist/ui/assets/index-0Q2eNytX.js +110 -0
- package/dist/ui/index.html +1 -1
- package/node_modules/@hono/node-server/dist/index.cjs +151 -9
- package/node_modules/@hono/node-server/dist/index.mjs +151 -9
- package/node_modules/@hono/node-server/package.json +1 -1
- package/node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type/README.md +2 -0
- package/node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type/dist/index.d.ts +20 -0
- package/node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type/dist/index.js +20 -14
- package/node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type/dist/index.js.map +1 -1
- package/node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type/package.json +1 -1
- package/node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type/README.md +2 -0
- package/node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type/dist/index.d.ts +20 -0
- package/node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type/dist/index.js +20 -14
- package/node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type/dist/index.js.map +1 -1
- package/node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type/package.json +1 -1
- package/node_modules/eventsource-parser/dist/index.cjs +2 -2
- package/node_modules/eventsource-parser/dist/index.cjs.map +1 -1
- package/node_modules/eventsource-parser/dist/index.js +2 -2
- package/node_modules/eventsource-parser/dist/index.js.map +1 -1
- package/node_modules/eventsource-parser/package.json +1 -1
- package/node_modules/eventsource-parser/src/parse.ts +4 -2
- package/node_modules/hono/dist/cjs/client/client.js +3 -3
- package/node_modules/hono/dist/cjs/jsx/intrinsic-element/components.js +1 -1
- package/node_modules/hono/dist/cjs/middleware/cors/index.js +15 -12
- package/node_modules/hono/dist/cjs/middleware/etag/digest.js +47 -1
- package/node_modules/hono/dist/cjs/middleware/secure-headers/secure-headers.js +6 -3
- package/node_modules/hono/dist/cjs/utils/url.js +2 -2
- package/node_modules/hono/dist/client/client.js +3 -3
- package/node_modules/hono/dist/jsx/intrinsic-element/components.js +1 -1
- package/node_modules/hono/dist/middleware/cors/index.js +15 -12
- package/node_modules/hono/dist/middleware/etag/digest.js +47 -1
- package/node_modules/hono/dist/middleware/secure-headers/secure-headers.js +6 -3
- package/node_modules/hono/dist/types/middleware/secure-headers/permissions-policy.d.ts +3 -3
- package/node_modules/hono/dist/utils/url.js +2 -2
- package/node_modules/hono/package.json +1 -1
- package/node_modules/ip-address/README.md +134 -134
- package/node_modules/ip-address/dist/address-error.d.ts +11 -0
- package/node_modules/ip-address/dist/address-error.js.map +1 -1
- package/node_modules/ip-address/dist/ipv4.d.ts +22 -6
- package/node_modules/ip-address/dist/ipv4.js +22 -6
- package/node_modules/ip-address/dist/ipv4.js.map +1 -1
- package/node_modules/ip-address/dist/ipv6.d.ts +10 -3
- package/node_modules/ip-address/dist/ipv6.js +26 -17
- package/node_modules/ip-address/dist/ipv6.js.map +1 -1
- package/node_modules/ip-address/package.json +1 -1
- package/package.json +2 -2
- package/dist/ui/assets/index-DHT9QCrE.js +0 -110
package/README.md
CHANGED
|
@@ -119,7 +119,16 @@ suveren-gateway stop # stop a detached run
|
|
|
119
119
|
|
|
120
120
|
Open `http://localhost:3400`. The MCP server is at `http://localhost:3430`.
|
|
121
121
|
|
|
122
|
-
To upgrade later
|
|
122
|
+
To upgrade later, run these two commands:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
npm install -g @suveren/gateway@latest
|
|
126
|
+
suveren-gateway restart
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
(Written as two lines on purpose: `&&` is not a valid separator in Windows
|
|
130
|
+
PowerShell 5.1, which is what ships with Windows. Use `;` there if you want
|
|
131
|
+
them on one line.)
|
|
123
132
|
|
|
124
133
|
### Connecting an MCP client
|
|
125
134
|
|
|
@@ -216,8 +216,7 @@
|
|
|
216
216
|
"bounds": {
|
|
217
217
|
"recipient_max": "0",
|
|
218
218
|
"send_daily_max": "0",
|
|
219
|
-
"read_max_age_days": "30"
|
|
220
|
-
"read_daily_max": "100"
|
|
219
|
+
"read_max_age_days": "30"
|
|
221
220
|
},
|
|
222
221
|
"context": {
|
|
223
222
|
"allowed_recipients": "",
|
|
@@ -227,8 +226,7 @@
|
|
|
227
226
|
"ttl": 2592000,
|
|
228
227
|
"tags": [
|
|
229
228
|
"read-only",
|
|
230
|
-
"30 days"
|
|
231
|
-
"100 reads/day"
|
|
229
|
+
"30 days"
|
|
232
230
|
]
|
|
233
231
|
},
|
|
234
232
|
{
|
|
@@ -239,8 +237,7 @@
|
|
|
239
237
|
"bounds": {
|
|
240
238
|
"recipient_max": "5",
|
|
241
239
|
"send_daily_max": "20",
|
|
242
|
-
"read_max_age_days": "90"
|
|
243
|
-
"read_daily_max": "200"
|
|
240
|
+
"read_max_age_days": "90"
|
|
244
241
|
},
|
|
245
242
|
"context": {
|
|
246
243
|
"allowed_recipients": "",
|
|
@@ -685,6 +685,64 @@ function createAuthRouter(vault2, logoutAuth, loginRateLimit2) {
|
|
|
685
685
|
|
|
686
686
|
// src/routes/vault.ts
|
|
687
687
|
import { Router as Router2 } from "express";
|
|
688
|
+
|
|
689
|
+
// src/lib/credential-meta.ts
|
|
690
|
+
var META_KEY = "__updatedAt";
|
|
691
|
+
var MIN_PREVIEW_LENGTH = 12;
|
|
692
|
+
var PREFIX_RE = /^([A-Za-z][A-Za-z0-9]*(?:[-_][A-Za-z0-9]+)*[-_])/;
|
|
693
|
+
var MAX_PREFIX_LENGTH = 12;
|
|
694
|
+
var HIDDEN_FLOOR = 12;
|
|
695
|
+
function previewSecret(value) {
|
|
696
|
+
if (value.length < MIN_PREVIEW_LENGTH) return { preview: null };
|
|
697
|
+
const match = PREFIX_RE.exec(value)?.[1] ?? "";
|
|
698
|
+
const prefix = match && match.length <= MAX_PREFIX_LENGTH && value.length - match.length - 4 >= HIDDEN_FLOOR ? match : "";
|
|
699
|
+
if (!prefix && value.length - 4 < 8) return { preview: null };
|
|
700
|
+
return { preview: `${prefix}\u2026${value.slice(-4)}` };
|
|
701
|
+
}
|
|
702
|
+
var AI_CONFIG_TEXT_FIELDS = /* @__PURE__ */ new Set(["provider", "endpoint", "model"]);
|
|
703
|
+
function textFieldsFor(name, manifests) {
|
|
704
|
+
if (name === "ai-config") return AI_CONFIG_TEXT_FIELDS;
|
|
705
|
+
const list = Array.isArray(manifests) ? manifests : [];
|
|
706
|
+
const manifest = list.find((m) => m?.id === name);
|
|
707
|
+
const out = /* @__PURE__ */ new Set();
|
|
708
|
+
for (const f of manifest?.credentials?.fields ?? []) {
|
|
709
|
+
if (typeof f?.key === "string" && f?.type === "text") out.add(f.key);
|
|
710
|
+
}
|
|
711
|
+
return out;
|
|
712
|
+
}
|
|
713
|
+
function credentialView(cred, textFields) {
|
|
714
|
+
const fields = {};
|
|
715
|
+
const secrets = {};
|
|
716
|
+
let updatedAt;
|
|
717
|
+
for (const [key, value] of Object.entries(cred)) {
|
|
718
|
+
if (key === META_KEY) {
|
|
719
|
+
updatedAt = value;
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (textFields.has(key)) fields[key] = value;
|
|
723
|
+
else secrets[key] = previewSecret(value);
|
|
724
|
+
}
|
|
725
|
+
return {
|
|
726
|
+
configured: true,
|
|
727
|
+
fieldNames: Object.keys(fields).concat(Object.keys(secrets)),
|
|
728
|
+
fields,
|
|
729
|
+
secrets,
|
|
730
|
+
...updatedAt ? { updatedAt } : {}
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// src/routes/vault.ts
|
|
735
|
+
var manifestCache = null;
|
|
736
|
+
async function cachedManifests() {
|
|
737
|
+
if (manifestCache && Date.now() - manifestCache.at < 6e4) return manifestCache.value;
|
|
738
|
+
try {
|
|
739
|
+
const m = await getManifests();
|
|
740
|
+
manifestCache = { at: Date.now(), value: m?.manifests ?? m };
|
|
741
|
+
} catch {
|
|
742
|
+
manifestCache = { at: Date.now(), value: [] };
|
|
743
|
+
}
|
|
744
|
+
return manifestCache.value;
|
|
745
|
+
}
|
|
688
746
|
function createVaultRouter(vault2) {
|
|
689
747
|
const router = Router2();
|
|
690
748
|
router.get("/status", (_req, res) => {
|
|
@@ -696,14 +754,14 @@ function createVaultRouter(vault2) {
|
|
|
696
754
|
serviceCount: services.length
|
|
697
755
|
});
|
|
698
756
|
});
|
|
699
|
-
router.get("/credentials/:name", (req, res) => {
|
|
757
|
+
router.get("/credentials/:name", async (req, res) => {
|
|
700
758
|
const { name } = req.params;
|
|
701
759
|
const cred = vault2.getCredential(name);
|
|
702
760
|
if (!cred) {
|
|
703
761
|
res.json({ configured: false });
|
|
704
762
|
return;
|
|
705
763
|
}
|
|
706
|
-
res.json(
|
|
764
|
+
res.json(credentialView(cred, textFieldsFor(name, await cachedManifests())));
|
|
707
765
|
});
|
|
708
766
|
router.put("/credentials/:name", async (req, res) => {
|
|
709
767
|
const { name } = req.params;
|
|
@@ -714,6 +772,7 @@ function createVaultRouter(vault2) {
|
|
|
714
772
|
if (existing) fields = { ...existing, ...incoming };
|
|
715
773
|
} catch {
|
|
716
774
|
}
|
|
775
|
+
fields = { ...fields, [META_KEY]: (/* @__PURE__ */ new Date()).toISOString() };
|
|
717
776
|
vault2.setCredential(name, fields);
|
|
718
777
|
try {
|
|
719
778
|
await pushServiceCredentials(name, fields);
|
|
@@ -806,6 +865,7 @@ function createVaultRouter(vault2) {
|
|
|
806
865
|
import { Router as Router3 } from "express";
|
|
807
866
|
|
|
808
867
|
// src/lib/ai-client.ts
|
|
868
|
+
var CHAT_TOKEN_BUDGET = 4e3;
|
|
809
869
|
var PROVIDER_PRESETS = {
|
|
810
870
|
ollama: {
|
|
811
871
|
provider: "ollama",
|
|
@@ -833,6 +893,30 @@ var PROVIDER_PRESETS = {
|
|
|
833
893
|
model: "meta-llama/Llama-3-8b-chat-hf"
|
|
834
894
|
}
|
|
835
895
|
};
|
|
896
|
+
function explainEmptyReply(choice, usage, budget) {
|
|
897
|
+
const reasoningTokens = usage?.completion_tokens_details?.reasoning_tokens ?? 0;
|
|
898
|
+
const finish = choice?.finish_reason;
|
|
899
|
+
const hadReasoning = reasoningTokens > 0 || Boolean(choice?.message?.reasoning_content || choice?.message?.reasoning);
|
|
900
|
+
if (finish === "length" && hadReasoning) {
|
|
901
|
+
return `The model is a reasoning model and spent its whole ${budget}-token budget` + (reasoningTokens ? ` (${reasoningTokens} tokens)` : "") + " thinking, leaving nothing for the answer. Raise the token limit or choose a non-reasoning model in Settings.";
|
|
902
|
+
}
|
|
903
|
+
if (finish === "length") {
|
|
904
|
+
return `The model hit the ${budget}-token limit before writing any answer.`;
|
|
905
|
+
}
|
|
906
|
+
if (hadReasoning) {
|
|
907
|
+
return "The model returned only internal reasoning and no answer \u2014 it may not be compatible with this endpoint. Try a different model in Settings.";
|
|
908
|
+
}
|
|
909
|
+
if (finish === "content_filter") {
|
|
910
|
+
return "The provider's content filter blocked the response.";
|
|
911
|
+
}
|
|
912
|
+
if (choice?.message?.tool_calls?.length) {
|
|
913
|
+
return "The model tried to call a tool instead of answering. Choose a model without forced tool use.";
|
|
914
|
+
}
|
|
915
|
+
if (!choice) {
|
|
916
|
+
return "The provider returned no choices. Check the model name in Settings.";
|
|
917
|
+
}
|
|
918
|
+
return `The model returned an empty answer${finish ? ` (finish_reason: ${finish})` : ""}.`;
|
|
919
|
+
}
|
|
836
920
|
var SYSTEM_PROMPTS = {
|
|
837
921
|
intent: `You are a reviewing assistant helping a human articulate their intent for an AI agent authorization.
|
|
838
922
|
|
|
@@ -1003,13 +1087,20 @@ ${request.currentText}
|
|
|
1003
1087
|
model: config.model,
|
|
1004
1088
|
messages,
|
|
1005
1089
|
stream: false,
|
|
1006
|
-
options: { temperature: 0.3, num_predict:
|
|
1090
|
+
options: { temperature: 0.3, num_predict: CHAT_TOKEN_BUDGET }
|
|
1007
1091
|
}),
|
|
1008
1092
|
signal: AbortSignal.timeout(6e4)
|
|
1009
1093
|
});
|
|
1010
1094
|
if (!response.ok) throw new Error(`Ollama: ${response.status}`);
|
|
1011
1095
|
const data = await response.json();
|
|
1012
|
-
|
|
1096
|
+
const content = data.message?.content?.trim();
|
|
1097
|
+
if (!content) {
|
|
1098
|
+
return {
|
|
1099
|
+
success: false,
|
|
1100
|
+
error: data.message?.thinking ? "The model returned only internal reasoning and no answer. Try a non-thinking model." : `The model returned an empty answer. Check that "${config.model}" is pulled and running in Ollama.`
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
reply = content;
|
|
1013
1104
|
} else {
|
|
1014
1105
|
const headers = { "Content-Type": "application/json" };
|
|
1015
1106
|
if (config.apiKey) headers["Authorization"] = `Bearer ${config.apiKey}`;
|
|
@@ -1020,7 +1111,10 @@ ${request.currentText}
|
|
|
1020
1111
|
model: config.model,
|
|
1021
1112
|
messages,
|
|
1022
1113
|
temperature: 0.3,
|
|
1023
|
-
|
|
1114
|
+
// Reasoning models charge their thinking against this same budget,
|
|
1115
|
+
// so a small number here produces an empty answer rather than a
|
|
1116
|
+
// short one. 4000 leaves room to think AND reply.
|
|
1117
|
+
max_tokens: CHAT_TOKEN_BUDGET
|
|
1024
1118
|
}),
|
|
1025
1119
|
signal: AbortSignal.timeout(6e4)
|
|
1026
1120
|
});
|
|
@@ -1029,7 +1123,14 @@ ${request.currentText}
|
|
|
1029
1123
|
throw new Error(`AI provider: ${response.status} - ${errorText}`);
|
|
1030
1124
|
}
|
|
1031
1125
|
const data = await response.json();
|
|
1032
|
-
|
|
1126
|
+
const content = data.choices?.[0]?.message?.content?.trim();
|
|
1127
|
+
if (!content) {
|
|
1128
|
+
return {
|
|
1129
|
+
success: false,
|
|
1130
|
+
error: explainEmptyReply(data.choices?.[0], data.usage, CHAT_TOKEN_BUDGET)
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
reply = content;
|
|
1033
1134
|
}
|
|
1034
1135
|
return { success: true, reply };
|
|
1035
1136
|
} catch (error) {
|
|
@@ -2327,7 +2428,7 @@ function resolveOrigin(req) {
|
|
|
2327
2428
|
return `${req.protocol}://${req.get("host")}`;
|
|
2328
2429
|
}
|
|
2329
2430
|
var oauthRedirectUris = /* @__PURE__ */ new Map();
|
|
2330
|
-
var
|
|
2431
|
+
var manifestCache2 = null;
|
|
2331
2432
|
function resolvePath(obj, path) {
|
|
2332
2433
|
const v = path.split(".").reduce((acc, key) => {
|
|
2333
2434
|
return acc && typeof acc === "object" ? acc[key] : void 0;
|
|
@@ -2347,15 +2448,15 @@ function decodeJwtEmail(idToken) {
|
|
|
2347
2448
|
}
|
|
2348
2449
|
}
|
|
2349
2450
|
async function getOAuthManifest(integrationId) {
|
|
2350
|
-
if (!
|
|
2451
|
+
if (!manifestCache2) {
|
|
2351
2452
|
try {
|
|
2352
2453
|
const data = await getManifests();
|
|
2353
|
-
|
|
2454
|
+
manifestCache2 = data.manifests;
|
|
2354
2455
|
} catch {
|
|
2355
2456
|
return null;
|
|
2356
2457
|
}
|
|
2357
2458
|
}
|
|
2358
|
-
return
|
|
2459
|
+
return manifestCache2?.find((m) => m.id === integrationId && m.oauth) ?? null;
|
|
2359
2460
|
}
|
|
2360
2461
|
app.get("/auth/oauth/:integrationId/start", async (req, res) => {
|
|
2361
2462
|
const { integrationId } = req.params;
|
package/dist/mcp-server/http.mjs
CHANGED
|
@@ -137,7 +137,7 @@ var SPClient = class {
|
|
|
137
137
|
*/
|
|
138
138
|
async postReceipt(data) {
|
|
139
139
|
const body = JSON.stringify(data);
|
|
140
|
-
const maxAttempts = data.idempotencyKey ? this.receiptRetry.maxAttempts : 1;
|
|
140
|
+
const maxAttempts = data.idempotencyKey || data.proposalId ? this.receiptRetry.maxAttempts : 1;
|
|
141
141
|
let lastError;
|
|
142
142
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
143
143
|
const isLast = attempt === maxAttempts;
|
|
@@ -2403,6 +2403,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
2403
2403
|
`[Suveren MCP] Warning: proposal ${proposal.id} has no action_type in executionContext. Bounds check may be skipped. Fix the integration manifest for ${proposal.tool}.`
|
|
2404
2404
|
);
|
|
2405
2405
|
}
|
|
2406
|
+
const cachedAuth = state2.cache.getAllAuthorizations().find((a) => a.authorizationId === proposal.authorizationId);
|
|
2406
2407
|
let receiptId;
|
|
2407
2408
|
try {
|
|
2408
2409
|
const binding = computeContentBinding(
|
|
@@ -2413,6 +2414,9 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
2413
2414
|
);
|
|
2414
2415
|
const { receipt } = await state2.spClient.postReceipt({
|
|
2415
2416
|
authorizationId: proposal.authorizationId,
|
|
2417
|
+
// Optional cross-check — the AS fails closed on a mismatch. Parity
|
|
2418
|
+
// with the automatic path (tool-proxy.ts).
|
|
2419
|
+
boundsHash: cachedAuth?.boundsHash,
|
|
2416
2420
|
profileId: proposal.profileId,
|
|
2417
2421
|
action: proposal.tool,
|
|
2418
2422
|
actionType: proposalActionType,
|
|
@@ -2420,6 +2424,13 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
2420
2424
|
amount: typeof proposal.executionContext.amount === "number" ? proposal.executionContext.amount : void 0,
|
|
2421
2425
|
proposalId: proposal.id,
|
|
2422
2426
|
toolArgs: proposal.toolArgs,
|
|
2427
|
+
// Deliberately NO idempotencyKey: the spec has review-mode commits carry
|
|
2428
|
+
// a proposalId INSTEAD of a key — the proposal's committed→executed
|
|
2429
|
+
// transition is their replay protection, and the AS replays the ORIGINAL
|
|
2430
|
+
// receipt to the same caller if a retry arrives after it committed.
|
|
2431
|
+
// SPClient therefore treats proposalId as retry-safe on its own; a
|
|
2432
|
+
// PROPOSAL_ALREADY_EXECUTED answer is still a definitive rejection
|
|
2433
|
+
// (never retried) and means a *different* caller consumed the proposal.
|
|
2423
2434
|
...binding ?? {}
|
|
2424
2435
|
});
|
|
2425
2436
|
receiptId = typeof receipt?.id === "string" ? receipt.id : void 0;
|
|
@@ -2451,7 +2462,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
|
|
|
2451
2462
|
let outgoingArgs = proposal.toolArgs;
|
|
2452
2463
|
if (discovered && receiptId) {
|
|
2453
2464
|
if (shouldAttachFooter()) {
|
|
2454
|
-
outgoingArgs = appendVerificationFooter(discovered, outgoingArgs, receiptId);
|
|
2465
|
+
outgoingArgs = appendVerificationFooter(discovered, outgoingArgs, receiptId, cachedAuth?.subjects?.[0]);
|
|
2455
2466
|
}
|
|
2456
2467
|
outgoingArgs = attachReceiptId(discovered, outgoingArgs, receiptId);
|
|
2457
2468
|
outgoingArgs = encodeOutgoingArgs(discovered, outgoingArgs);
|
|
@@ -2672,7 +2683,12 @@ function createMcpServer(state2, integrationManager2) {
|
|
|
2672
2683
|
}
|
|
2673
2684
|
}
|
|
2674
2685
|
for (const tool of allTools) {
|
|
2675
|
-
|
|
2686
|
+
const existing = proxiedTools.get(tool.namespacedName);
|
|
2687
|
+
if (existing) {
|
|
2688
|
+
if (existing.tool === tool) continue;
|
|
2689
|
+
existing.registered.remove();
|
|
2690
|
+
proxiedTools.delete(tool.namespacedName);
|
|
2691
|
+
}
|
|
2676
2692
|
const handler = createGatedToolHandler(tool, integrationManager2, state2);
|
|
2677
2693
|
const zodShape = jsonSchemaToZodShape(tool.inputSchema);
|
|
2678
2694
|
const description = buildProxiedToolDescription(tool, state2);
|
|
@@ -2869,6 +2885,24 @@ var IntegrationManager = class {
|
|
|
2869
2885
|
}
|
|
2870
2886
|
running = /* @__PURE__ */ new Map();
|
|
2871
2887
|
onToolsChanged = null;
|
|
2888
|
+
/**
|
|
2889
|
+
* Per-integration operation queue. Start/stop for one id are serialized
|
|
2890
|
+
* through here; without it, a second start issued while the first is still
|
|
2891
|
+
* installing/handshaking sees `running.has(id) === false`, skips the stop,
|
|
2892
|
+
* and both children come up — last `running.set` wins, the loser's process
|
|
2893
|
+
* leaks, and the surviving GATING is whichever start happened to finish
|
|
2894
|
+
* last. That last-writer-wins gating is a fail-open: a permissive manifest
|
|
2895
|
+
* config can silently replace a stricter explicit one (observed as the
|
|
2896
|
+
* read-gate value-mismatch flake).
|
|
2897
|
+
*/
|
|
2898
|
+
opQueues = /* @__PURE__ */ new Map();
|
|
2899
|
+
/** Run `task` after every previously queued operation for `id` has settled. */
|
|
2900
|
+
runExclusive(id, task) {
|
|
2901
|
+
const prev = this.opQueues.get(id) ?? Promise.resolve();
|
|
2902
|
+
const run = prev.then(task, task);
|
|
2903
|
+
this.opQueues.set(id, run.then(() => void 0, () => void 0));
|
|
2904
|
+
return run;
|
|
2905
|
+
}
|
|
2872
2906
|
/**
|
|
2873
2907
|
* Register a callback invoked when the tool set changes
|
|
2874
2908
|
* (integration started, stopped, or crashed).
|
|
@@ -3018,10 +3052,19 @@ var IntegrationManager = class {
|
|
|
3018
3052
|
* Start a downstream MCP server integration.
|
|
3019
3053
|
* Installs npm package on-demand if needed, resolves envKeys,
|
|
3020
3054
|
* spawns the process, connects as MCP client, and discovers tools.
|
|
3055
|
+
*
|
|
3056
|
+
* Serialized per id: a start issued while another start/stop for the same
|
|
3057
|
+
* id is in flight waits for it, then applies its own semantics (restart, or
|
|
3058
|
+
* keep-if-running with `skipIfRunning`). See `opQueues`.
|
|
3021
3059
|
*/
|
|
3022
|
-
async startIntegration(config) {
|
|
3023
|
-
|
|
3024
|
-
|
|
3060
|
+
async startIntegration(config, opts = {}) {
|
|
3061
|
+
return this.runExclusive(config.id, () => this.startIntegrationLocked(config, opts));
|
|
3062
|
+
}
|
|
3063
|
+
async startIntegrationLocked(config, opts) {
|
|
3064
|
+
const existing = this.running.get(config.id);
|
|
3065
|
+
if (existing) {
|
|
3066
|
+
if (opts.skipIfRunning) return existing.tools;
|
|
3067
|
+
await this.stopIntegrationLocked(config.id);
|
|
3025
3068
|
}
|
|
3026
3069
|
if (config.npmPackage) {
|
|
3027
3070
|
await this.ensureInstalled(config.npmPackage);
|
|
@@ -3076,6 +3119,7 @@ var IntegrationManager = class {
|
|
|
3076
3119
|
};
|
|
3077
3120
|
this.running.set(config.id, entry);
|
|
3078
3121
|
transport.onclose = () => {
|
|
3122
|
+
if (this.running.get(config.id)?.transport !== transport) return;
|
|
3079
3123
|
console.error(`[IntegrationManager] Transport closed for ${config.id}`);
|
|
3080
3124
|
this.handleCrash(config.id);
|
|
3081
3125
|
};
|
|
@@ -3084,8 +3128,15 @@ var IntegrationManager = class {
|
|
|
3084
3128
|
}
|
|
3085
3129
|
/**
|
|
3086
3130
|
* Stop a running integration, closing its transport and removing its tools.
|
|
3131
|
+
*
|
|
3132
|
+
* Serialized per id: a stop issued while a start is in flight waits and
|
|
3133
|
+
* then stops the just-started instance, instead of silently doing nothing
|
|
3134
|
+
* because the entry wasn't in `running` yet.
|
|
3087
3135
|
*/
|
|
3088
3136
|
async stopIntegration(id) {
|
|
3137
|
+
return this.runExclusive(id, () => this.stopIntegrationLocked(id));
|
|
3138
|
+
}
|
|
3139
|
+
async stopIntegrationLocked(id) {
|
|
3089
3140
|
const entry = this.running.get(id);
|
|
3090
3141
|
if (!entry) return;
|
|
3091
3142
|
entry.transport.onclose = void 0;
|
|
@@ -3308,7 +3359,7 @@ var IntegrationManager = class {
|
|
|
3308
3359
|
console.error(`[IntegrationManager] ${id} crashed, respawning in ${delay}ms (attempt ${attempts + 1}/${MAX_RESPAWN_ATTEMPTS})`);
|
|
3309
3360
|
setTimeout(async () => {
|
|
3310
3361
|
try {
|
|
3311
|
-
await this.startIntegration(entry.config);
|
|
3362
|
+
await this.startIntegration(entry.config, { skipIfRunning: true });
|
|
3312
3363
|
const newEntry = this.running.get(id);
|
|
3313
3364
|
if (newEntry) {
|
|
3314
3365
|
newEntry.respawnAttempts = attempts + 1;
|
|
@@ -3833,7 +3884,7 @@ async function startOneIntegration(config) {
|
|
|
3833
3884
|
const manifest = getManifest(config.id);
|
|
3834
3885
|
const effectiveConfig = manifest ? { ...config, toolGating: manifest.toolGating, npmPackage: manifest.npmPackage ?? config.npmPackage } : config;
|
|
3835
3886
|
try {
|
|
3836
|
-
await integrationManager.startIntegration(effectiveConfig);
|
|
3887
|
+
await integrationManager.startIntegration(effectiveConfig, { skipIfRunning: true });
|
|
3837
3888
|
} catch (err) {
|
|
3838
3889
|
console.error(`[Suveren MCP] Failed to start integration ${config.id}:`, err);
|
|
3839
3890
|
}
|