@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai/mcp",
3
- "version": "0.3.29",
3
+ "version": "0.3.31",
4
4
  "mcpName": "io.github.ariekogan/ateam-mcp",
5
5
  "description": "A-Team MCP Server — build, validate, and deploy multi-agent solutions from any AI environment",
6
6
  "type": "module",
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 || "main",
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 || "main"}${explicit ? ", explicit" : ""}${urlNote}${masterNote})`);
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
- return { tenant: tenant || "main", apiKey };
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} (bearer: ${bearerToken.substring(0, 25)}...)`);
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 || "main";
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
- // Key: token string, Value: { token, createdAt }
41
- const recentTokens = new Map();
42
- const TOKEN_TTL = 60 * 60 * 1000; // 60 minutes
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 ? ` Auth: ${auth.substring(0, 30)}...` : ""}${MCP_PATHS.includes(url.split("?")[0]) ? ` Accept: ${req.headers.accept || "(none)"}` : ""}`);
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
- recentTokens.set(data.access_token, {
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 from /token response (${recentTokens.size} active)`);
122
+ console.log(`[Auth] Cached OAuth token for ip=${ip} (${recentTokensByIp.size} active IPs)`);
111
123
  // Prune expired
112
- for (const [k, v] of recentTokens) {
113
- if (Date.now() - v.createdAt > TOKEN_TTL) recentTokens.delete(k);
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 but we have a recently
131
- // exchanged token, inject it. Simple cache lookup never blocks.
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 token = getNewestToken();
135
- if (token) {
136
- req.headers.authorization = `Bearer ${token}`;
137
- const idx = req.rawHeaders.findIndex((h) => h.toLowerCase() === "authorization");
138
- if (idx !== -1) {
139
- req.rawHeaders[idx + 1] = `Bearer ${token}`;
140
- } else {
141
- req.rawHeaders.push("Authorization", `Bearer ${token}`);
142
- }
143
- console.log(`[Auth] Auto-injected token into ${req.method} ${req.originalUrl || req.url}`);
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
- /** Returns the most recently issued non-expired token, or null. */
362
- function getNewestToken() {
363
- let newest = null;
364
- for (const [, entry] of recentTokens) {
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: code=${code?.substring(0, 8)}... found=${!!entry} codes=${this.codes.size}`
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: code=${code?.substring(0, 8)}... found=${!!entry} client=${client?.client_id?.substring(0, 8)}...`
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: ${token.substring(0, 30)}...`);
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 ? ` Auth: ${auth.substring(0, 30)}...` : ""}`);
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] Token ${key.substring(0, 20)}... expired (age: ${Math.round(ageMs / 1000)}s)`);
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): ${data.access_token.substring(0, 25)}...`);
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): ${data.access_token.substring(0, 25)}...`);
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 || "main";
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