@ateam-ai/mcp 0.3.29 → 0.3.31
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/package.json +1 -1
- package/src/api.js +37 -6
- package/src/http.js +43 -30
- package/src/stub.js +7 -7
- package/src/tools.js +34 -2
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -65,9 +65,20 @@ export function setSessionCredentials(sessionId, { tenant, apiKey, apiUrl, expli
|
|
|
65
65
|
const parsed = parseApiKey(apiKey);
|
|
66
66
|
if (parsed.tenant) resolvedTenant = parsed.tenant;
|
|
67
67
|
}
|
|
68
|
+
// Fail loudly — silent fallback to "main" previously let malformed API keys
|
|
69
|
+
// or missing tenant args silently pivot all operations onto the wrong tenant.
|
|
70
|
+
// Matches the pattern we killed in ADAS connectors (memory-mcp, docs-index-mcp,
|
|
71
|
+
// nutrition-mcp) — `|| "default"` was the #1 source of cross-tenant leaks.
|
|
72
|
+
if (!resolvedTenant) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`setSessionCredentials: tenant could not be resolved for session ${sessionId} ` +
|
|
75
|
+
`(tenant arg ${tenant ? "present" : "missing"}, apiKey ${apiKey ? "present but malformed (expected adas_<tenant>_<hex>)" : "absent"}). ` +
|
|
76
|
+
`Refusing to fall back to a default tenant.`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
68
79
|
const existing = sessions.get(sessionId);
|
|
69
80
|
sessions.set(sessionId, {
|
|
70
|
-
tenant: resolvedTenant
|
|
81
|
+
tenant: resolvedTenant,
|
|
71
82
|
apiKey,
|
|
72
83
|
apiUrl: apiUrl || existing?.apiUrl || null,
|
|
73
84
|
authExplicit: explicit || existing?.authExplicit || false,
|
|
@@ -77,7 +88,7 @@ export function setSessionCredentials(sessionId, { tenant, apiKey, apiUrl, expli
|
|
|
77
88
|
});
|
|
78
89
|
const urlNote = apiUrl ? `, url: ${apiUrl}` : "";
|
|
79
90
|
const masterNote = masterKey ? ", MASTER MODE" : "";
|
|
80
|
-
console.log(`[Auth] Credentials set for session ${sessionId} (tenant: ${resolvedTenant
|
|
91
|
+
console.log(`[Auth] Credentials set for session ${sessionId} (tenant: ${resolvedTenant}${explicit ? ", explicit" : ""}${urlNote}${masterNote})`);
|
|
81
92
|
}
|
|
82
93
|
|
|
83
94
|
/**
|
|
@@ -121,7 +132,18 @@ export function getCredentials(sessionId) {
|
|
|
121
132
|
const parsed = parseApiKey(apiKey);
|
|
122
133
|
if (parsed.tenant) tenant = parsed.tenant;
|
|
123
134
|
}
|
|
124
|
-
|
|
135
|
+
// If apiKey is present but tenant couldn't be derived, the key is malformed.
|
|
136
|
+
// Previously fell back to "main" — this silently routed credentials to the
|
|
137
|
+
// wrong tenant. Now we fail loudly.
|
|
138
|
+
if (apiKey && !tenant) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`getCredentials: apiKey is present (env ADAS_API_KEY) but tenant could not be resolved ` +
|
|
141
|
+
`(missing ADAS_TENANT env and apiKey is malformed — expected format adas_<tenant>_<hex>). ` +
|
|
142
|
+
`Refusing to fall back to a default tenant.`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
// No apiKey at all = unauthenticated; return nulls (callers check apiKey.length).
|
|
146
|
+
return { tenant: tenant || null, apiKey };
|
|
125
147
|
}
|
|
126
148
|
|
|
127
149
|
/**
|
|
@@ -186,7 +208,7 @@ export function clearSession(sessionId) {
|
|
|
186
208
|
/** Bind a session to its OAuth bearer token. Called from seedCredentials. */
|
|
187
209
|
export function bindSessionBearer(sessionId, bearerToken) {
|
|
188
210
|
sessionBearers.set(sessionId, bearerToken);
|
|
189
|
-
console.log(`[Auth] Bearer bound for session ${sessionId}
|
|
211
|
+
console.log(`[Auth] Bearer bound for session ${sessionId}`);
|
|
190
212
|
}
|
|
191
213
|
|
|
192
214
|
/** Store ateam_auth override for this user (by bearer). Called from tools.js. */
|
|
@@ -300,11 +322,20 @@ export function getSessionStats() {
|
|
|
300
322
|
function headers(sessionId) {
|
|
301
323
|
const session = sessionId ? sessions.get(sessionId) : null;
|
|
302
324
|
|
|
303
|
-
// Master mode: use shared secret auth (x-adas-token) instead of API key
|
|
325
|
+
// Master mode: use shared secret auth (x-adas-token) instead of API key.
|
|
326
|
+
// A master-mode session MUST have an active tenant (set via ateam_auth or
|
|
327
|
+
// switchTenant). Silent fallback to "main" previously masked configuration
|
|
328
|
+
// bugs and could pivot a master-key caller onto the wrong tenant.
|
|
304
329
|
if (session?.masterKey) {
|
|
330
|
+
if (!session.tenant) {
|
|
331
|
+
throw new Error(
|
|
332
|
+
`headers: master-mode session ${sessionId} has no active tenant — ` +
|
|
333
|
+
`caller must select a tenant via ateam_auth or switchTenant before making requests.`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
305
336
|
const h = { "Content-Type": "application/json" };
|
|
306
337
|
h["x-adas-token"] = session.masterKey;
|
|
307
|
-
h["X-ADAS-TENANT"] = session.tenant
|
|
338
|
+
h["X-ADAS-TENANT"] = session.tenant;
|
|
308
339
|
return h;
|
|
309
340
|
}
|
|
310
341
|
|
package/src/http.js
CHANGED
|
@@ -36,10 +36,19 @@ const transports = {};
|
|
|
36
36
|
// MCP paths — Claude.ai uses "/" (connector URL), others may use "/mcp"
|
|
37
37
|
const MCP_PATHS = ["/", "/mcp"];
|
|
38
38
|
|
|
39
|
-
// Recently exchanged tokens — for auto-injection into MCP requests
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
// Recently exchanged OAuth tokens — for auto-injection into MCP requests that
|
|
40
|
+
// follow the /token exchange within the same user's OAuth→MCP handshake window.
|
|
41
|
+
//
|
|
42
|
+
// ⚠️ SECURITY: This cache is scoped by CLIENT IP. A previous version keyed by
|
|
43
|
+
// token value and used `getNewestToken()` for injection — in multi-user HTTP
|
|
44
|
+
// mode (e.g., mcp.ateam-ai.com), that caused cross-user auth bypass: if User A
|
|
45
|
+
// completed OAuth and then User B sent an unauth'd MCP request, User B was
|
|
46
|
+
// injected with User A's token. IP-scoping prevents that: injection only
|
|
47
|
+
// happens for the same client IP that completed the token exchange.
|
|
48
|
+
//
|
|
49
|
+
// Key: client IP string, Value: { token, createdAt }
|
|
50
|
+
const recentTokensByIp = new Map();
|
|
51
|
+
const TOKEN_TTL = 5 * 60 * 1000; // 5 minutes — OAuth→MCP handshake window only
|
|
43
52
|
|
|
44
53
|
export function startHttpServer(port = 3100) {
|
|
45
54
|
const app = express();
|
|
@@ -50,7 +59,7 @@ export function startHttpServer(port = 3100) {
|
|
|
50
59
|
const url = req.originalUrl || req.url;
|
|
51
60
|
const start = Date.now();
|
|
52
61
|
const auth = req.headers.authorization;
|
|
53
|
-
console.log(`[HTTP] >>> ${req.method} ${url}${auth ?
|
|
62
|
+
console.log(`[HTTP] >>> ${req.method} ${url}${auth ? " Auth: [Bearer ...]" : ""}${MCP_PATHS.includes(url.split("?")[0]) ? ` Accept: ${req.headers.accept || "(none)"}` : ""}`);
|
|
54
63
|
res.on("finish", () => {
|
|
55
64
|
console.log(`[HTTP] <<< ${req.method} ${url} → ${res.statusCode} (${Date.now() - start}ms)`);
|
|
56
65
|
});
|
|
@@ -103,14 +112,17 @@ export function startHttpServer(port = 3100) {
|
|
|
103
112
|
const origJson = res.json.bind(res);
|
|
104
113
|
res.json = (data) => {
|
|
105
114
|
if (data && data.access_token && res.statusCode >= 200 && res.statusCode < 300) {
|
|
106
|
-
|
|
115
|
+
// IP-scoped cache: only the same client IP can consume this token
|
|
116
|
+
// via auto-injection. Prevents cross-user token leakage in shared HTTP mode.
|
|
117
|
+
const ip = req.ip || "unknown";
|
|
118
|
+
recentTokensByIp.set(ip, {
|
|
107
119
|
token: data.access_token,
|
|
108
120
|
createdAt: Date.now(),
|
|
109
121
|
});
|
|
110
|
-
console.log(`[Auth] Cached token
|
|
122
|
+
console.log(`[Auth] Cached OAuth token for ip=${ip} (${recentTokensByIp.size} active IPs)`);
|
|
111
123
|
// Prune expired
|
|
112
|
-
for (const [k, v] of
|
|
113
|
-
if (Date.now() - v.createdAt > TOKEN_TTL)
|
|
124
|
+
for (const [k, v] of recentTokensByIp) {
|
|
125
|
+
if (Date.now() - v.createdAt > TOKEN_TTL) recentTokensByIp.delete(k);
|
|
114
126
|
}
|
|
115
127
|
}
|
|
116
128
|
return origJson(data);
|
|
@@ -127,21 +139,27 @@ export function startHttpServer(port = 3100) {
|
|
|
127
139
|
}
|
|
128
140
|
|
|
129
141
|
// ─── Token auto-injection middleware ────────────────────────────
|
|
130
|
-
// If a request has no Authorization header
|
|
131
|
-
//
|
|
142
|
+
// If a request has no Authorization header, check if THIS CLIENT IP recently
|
|
143
|
+
// completed /token exchange. If so, inject that IP's cached token. Prevents
|
|
144
|
+
// cross-user token leakage (fix for mcp-audit finding #1, round 009).
|
|
132
145
|
const autoInjectToken = (req, _res, next) => {
|
|
133
146
|
if (req.headers.authorization) return next();
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
147
|
+
const ip = req.ip || "unknown";
|
|
148
|
+
const entry = recentTokensByIp.get(ip);
|
|
149
|
+
if (!entry) return next();
|
|
150
|
+
if (Date.now() - entry.createdAt > TOKEN_TTL) {
|
|
151
|
+
recentTokensByIp.delete(ip);
|
|
152
|
+
return next();
|
|
153
|
+
}
|
|
154
|
+
const token = entry.token;
|
|
155
|
+
req.headers.authorization = `Bearer ${token}`;
|
|
156
|
+
const idx = req.rawHeaders.findIndex((h) => h.toLowerCase() === "authorization");
|
|
157
|
+
if (idx !== -1) {
|
|
158
|
+
req.rawHeaders[idx + 1] = `Bearer ${token}`;
|
|
159
|
+
} else {
|
|
160
|
+
req.rawHeaders.push("Authorization", `Bearer ${token}`);
|
|
144
161
|
}
|
|
162
|
+
console.log(`[Auth] Auto-injected IP-scoped token for ip=${ip} into ${req.method} ${req.originalUrl || req.url}`);
|
|
145
163
|
next();
|
|
146
164
|
};
|
|
147
165
|
|
|
@@ -358,15 +376,10 @@ export function startHttpServer(port = 3100) {
|
|
|
358
376
|
});
|
|
359
377
|
}
|
|
360
378
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
if (Date.now() - entry.createdAt > TOKEN_TTL) continue;
|
|
366
|
-
if (!newest || entry.createdAt > newest.createdAt) newest = entry;
|
|
367
|
-
}
|
|
368
|
-
return newest?.token || null;
|
|
369
|
-
}
|
|
379
|
+
// getNewestToken() removed — replaced by IP-scoped lookup in autoInjectToken.
|
|
380
|
+
// Global "newest token" injection caused cross-user auth bypass in multi-user
|
|
381
|
+
// HTTP deployments. IP scoping restores the intended semantics (same browser
|
|
382
|
+
// that completed OAuth gets its token injected on the follow-up MCP request).
|
|
370
383
|
|
|
371
384
|
/**
|
|
372
385
|
* Seed session credentials from the OAuth bearer token.
|
package/src/stub.js
CHANGED
|
@@ -68,7 +68,7 @@ class StubOAuthProvider {
|
|
|
68
68
|
async challengeForAuthorizationCode(_client, code) {
|
|
69
69
|
const entry = this.codes.get(code);
|
|
70
70
|
console.log(
|
|
71
|
-
`[Stub] challengeForAuthorizationCode:
|
|
71
|
+
`[Stub] challengeForAuthorizationCode: found=${!!entry} codes=${this.codes.size}`
|
|
72
72
|
);
|
|
73
73
|
if (!entry) throw new Error("Invalid code");
|
|
74
74
|
return entry.params.codeChallenge;
|
|
@@ -77,7 +77,7 @@ class StubOAuthProvider {
|
|
|
77
77
|
async exchangeAuthorizationCode(client, code) {
|
|
78
78
|
const entry = this.codes.get(code);
|
|
79
79
|
console.log(
|
|
80
|
-
`[Stub] exchangeAuthorizationCode:
|
|
80
|
+
`[Stub] exchangeAuthorizationCode: found=${!!entry} clientId=${client?.client_id ? "[present]" : "[absent]"}`
|
|
81
81
|
);
|
|
82
82
|
if (!entry) throw new Error("Invalid code");
|
|
83
83
|
this.codes.delete(code);
|
|
@@ -85,7 +85,7 @@ class StubOAuthProvider {
|
|
|
85
85
|
const token = entry.token;
|
|
86
86
|
this.tokens.set(token, { clientId: client.client_id });
|
|
87
87
|
|
|
88
|
-
console.log(`[Stub] Exchanged code for token:
|
|
88
|
+
console.log(`[Stub] Exchanged code for token: [redacted]`);
|
|
89
89
|
return {
|
|
90
90
|
access_token: token,
|
|
91
91
|
refresh_token: `rt_${token}`,
|
|
@@ -127,7 +127,7 @@ app.set("trust proxy", 1);
|
|
|
127
127
|
app.use((req, res, next) => {
|
|
128
128
|
const url = req.originalUrl || req.url;
|
|
129
129
|
const auth = req.headers.authorization;
|
|
130
|
-
console.log(`[Stub] >>> ${req.method} ${url}${auth ?
|
|
130
|
+
console.log(`[Stub] >>> ${req.method} ${url}${auth ? " Auth: [Bearer ...]" : ""}`);
|
|
131
131
|
res.on("finish", () => {
|
|
132
132
|
console.log(`[Stub] <<< ${req.method} ${url} → ${res.statusCode}`);
|
|
133
133
|
});
|
|
@@ -147,7 +147,7 @@ function getNewestToken() {
|
|
|
147
147
|
for (const [key, entry] of recentTokens) {
|
|
148
148
|
const ageMs = now - entry.createdAt;
|
|
149
149
|
if (ageMs > TOKEN_TTL) {
|
|
150
|
-
console.log(`[Stub]
|
|
150
|
+
console.log(`[Stub] A token expired (age: ${Math.round(ageMs / 1000)}s)`);
|
|
151
151
|
continue;
|
|
152
152
|
}
|
|
153
153
|
if (!newest || entry.createdAt > newest.createdAt) newest = entry;
|
|
@@ -175,7 +175,7 @@ app.use("/token", (req, res, next) => {
|
|
|
175
175
|
token: data.access_token,
|
|
176
176
|
createdAt: Date.now(),
|
|
177
177
|
});
|
|
178
|
-
console.log(`[Stub] ✅ Cached token from /token response (${recentTokens.size} active)
|
|
178
|
+
console.log(`[Stub] ✅ Cached token from /token response (${recentTokens.size} active)`);
|
|
179
179
|
// Clean up old tokens
|
|
180
180
|
for (const [k, v] of recentTokens) {
|
|
181
181
|
if (Date.now() - v.createdAt > TOKEN_TTL) recentTokens.delete(k);
|
|
@@ -195,7 +195,7 @@ app.use("/token", (req, res, next) => {
|
|
|
195
195
|
token: data.access_token,
|
|
196
196
|
createdAt: Date.now(),
|
|
197
197
|
});
|
|
198
|
-
console.log(`[Stub] ✅ Cached token from /token send (${recentTokens.size} active)
|
|
198
|
+
console.log(`[Stub] ✅ Cached token from /token send (${recentTokens.size} active)`);
|
|
199
199
|
}
|
|
200
200
|
} catch (_) {}
|
|
201
201
|
}
|
package/src/tools.js
CHANGED
|
@@ -391,6 +391,26 @@ export const tools = [
|
|
|
391
391
|
required: ["solution_id"],
|
|
392
392
|
},
|
|
393
393
|
},
|
|
394
|
+
{
|
|
395
|
+
name: "ateam_delete_skill",
|
|
396
|
+
core: true,
|
|
397
|
+
description:
|
|
398
|
+
"Delete a single skill from a deployed solution. Removes the skill from A-Team Core (kills the running MCP process, unregisters from skill registry, deletes from Mongo), removes the skill from solution.skills[] and solution.linked_skills, and deletes the skill's files from Builder FS. Use this to drop a skill without tearing down the whole solution.",
|
|
399
|
+
inputSchema: {
|
|
400
|
+
type: "object",
|
|
401
|
+
properties: {
|
|
402
|
+
solution_id: {
|
|
403
|
+
type: "string",
|
|
404
|
+
description: "The solution ID (e.g. 'personal-adas')",
|
|
405
|
+
},
|
|
406
|
+
skill_id: {
|
|
407
|
+
type: "string",
|
|
408
|
+
description: "The skill ID to remove (e.g. 'linkedin-agent')",
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
required: ["solution_id", "skill_id"],
|
|
412
|
+
},
|
|
413
|
+
},
|
|
394
414
|
{
|
|
395
415
|
name: "ateam_delete_connector",
|
|
396
416
|
core: true,
|
|
@@ -1148,6 +1168,7 @@ const TENANT_TOOLS = new Set([
|
|
|
1148
1168
|
"ateam_update",
|
|
1149
1169
|
"ateam_redeploy",
|
|
1150
1170
|
"ateam_delete_solution",
|
|
1171
|
+
"ateam_delete_skill",
|
|
1151
1172
|
"ateam_delete_connector",
|
|
1152
1173
|
"ateam_upload_connector",
|
|
1153
1174
|
"ateam_solution_chat",
|
|
@@ -1387,11 +1408,19 @@ const handlers = {
|
|
|
1387
1408
|
if (!api_key) {
|
|
1388
1409
|
return { ok: false, message: "Provide either api_key or master_key." };
|
|
1389
1410
|
}
|
|
1390
|
-
// Auto-extract tenant from key if not provided
|
|
1411
|
+
// Auto-extract tenant from key if not provided.
|
|
1412
|
+
// Fail loudly if neither the explicit tenant arg nor a parseable apiKey
|
|
1413
|
+
// yields a tenant — previously fell back to "main" silently.
|
|
1391
1414
|
let resolvedTenant = tenant;
|
|
1392
1415
|
if (!resolvedTenant) {
|
|
1393
1416
|
const parsed = parseApiKey(api_key);
|
|
1394
|
-
resolvedTenant = parsed.tenant
|
|
1417
|
+
resolvedTenant = parsed.tenant;
|
|
1418
|
+
}
|
|
1419
|
+
if (!resolvedTenant) {
|
|
1420
|
+
return {
|
|
1421
|
+
ok: false,
|
|
1422
|
+
message: `Could not resolve tenant from api_key (expected format: adas_<tenant>_<32hex>). Pass the "tenant" arg explicitly, or check that your API key is well-formed.`,
|
|
1423
|
+
};
|
|
1395
1424
|
}
|
|
1396
1425
|
// Normalize URL: strip trailing slash
|
|
1397
1426
|
const apiUrl = url ? url.replace(/\/+$/, "") : undefined;
|
|
@@ -2224,6 +2253,9 @@ const handlers = {
|
|
|
2224
2253
|
ateam_delete_solution: async ({ solution_id }, sid) =>
|
|
2225
2254
|
del(`/deploy/solutions/${solution_id}`, sid),
|
|
2226
2255
|
|
|
2256
|
+
ateam_delete_skill: async ({ solution_id, skill_id }, sid) =>
|
|
2257
|
+
del(`/deploy/solutions/${solution_id}/skills/${skill_id}`, sid),
|
|
2258
|
+
|
|
2227
2259
|
ateam_delete_connector: async ({ solution_id, connector_id }, sid) =>
|
|
2228
2260
|
del(`/deploy/solutions/${solution_id}/connectors/${connector_id}`, sid),
|
|
2229
2261
|
|