@ateam-ai/mcp 0.2.2 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai/mcp",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
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
@@ -11,11 +11,12 @@
11
11
  */
12
12
 
13
13
  const BASE_URL = process.env.ADAS_API_URL || "https://api.ateam-ai.com";
14
+ const CORE_URL = process.env.ADAS_CORE_URL || ""; // Direct Core access (for tenant list, etc.)
14
15
  const ENV_TENANT = process.env.ADAS_TENANT || "";
15
16
  const ENV_API_KEY = process.env.ADAS_API_KEY || "";
16
17
 
17
- // Request timeout (30 seconds)
18
- const REQUEST_TIMEOUT_MS = 30_000;
18
+ // Request timeout (120 seconds — deploys can take 60-90s)
19
+ const REQUEST_TIMEOUT_MS = 120_000;
19
20
 
20
21
  // Session TTL — sessions idle longer than this are swept
21
22
  const SESSION_TTL = 60 * 60 * 1000; // 60 minutes
@@ -27,6 +28,16 @@ const SWEEP_INTERVAL = 5 * 60 * 1000; // every 5 minutes
27
28
  // context: { activeSolutionId, lastSkillId, lastToolName }
28
29
  const sessions = new Map();
29
30
 
31
+ // ── Bearer-based auth (persistent across sessions) ──────────────
32
+ // The OAuth bearer token IS the user's API key (oauth.js exchangeAuthorizationCode).
33
+ // Each user has a unique bearer. MCP clients create new sessions per tool call,
34
+ // so we use the bearer as the persistent actor identity.
35
+ //
36
+ // When a user calls ateam_auth to override (e.g., switch tenants), the override
37
+ // is stored per bearer and applied to all future sessions from that user.
38
+ const authOverrides = new Map(); // bearerToken → { tenant, apiKey, updatedAt }
39
+ const sessionBearers = new Map(); // sessionId → bearerToken
40
+
30
41
  /**
31
42
  * Parse a tenant-embedded API key.
32
43
  * Format: adas_<tenant>_<32hex>
@@ -43,10 +54,12 @@ export function parseApiKey(key) {
43
54
  }
44
55
 
45
56
  /**
46
- * Set credentials for a session (called by ateam_auth tool).
57
+ * Set credentials for a session.
47
58
  * If tenant is not provided, it's auto-extracted from the key.
59
+ * Set explicit=true when called from ateam_auth (not from seedCredentials).
60
+ * Set masterKey for cross-tenant master mode (uses shared secret auth).
48
61
  */
49
- export function setSessionCredentials(sessionId, { tenant, apiKey }) {
62
+ export function setSessionCredentials(sessionId, { tenant, apiKey, apiUrl, explicit = false, masterKey = null }) {
50
63
  let resolvedTenant = tenant;
51
64
  if (!resolvedTenant && apiKey) {
52
65
  const parsed = parseApiKey(apiKey);
@@ -56,10 +69,36 @@ export function setSessionCredentials(sessionId, { tenant, apiKey }) {
56
69
  sessions.set(sessionId, {
57
70
  tenant: resolvedTenant || "main",
58
71
  apiKey,
72
+ apiUrl: apiUrl || existing?.apiUrl || null,
73
+ authExplicit: explicit || existing?.authExplicit || false,
74
+ masterKey: masterKey || existing?.masterKey || null,
59
75
  lastActivity: Date.now(),
60
76
  context: existing?.context || {},
61
77
  });
62
- console.log(`[Auth] Credentials set for session ${sessionId} (tenant: ${resolvedTenant || "main"})`);
78
+ const urlNote = apiUrl ? `, url: ${apiUrl}` : "";
79
+ const masterNote = masterKey ? ", MASTER MODE" : "";
80
+ console.log(`[Auth] Credentials set for session ${sessionId} (tenant: ${resolvedTenant || "main"}${explicit ? ", explicit" : ""}${urlNote}${masterNote})`);
81
+ }
82
+
83
+ /**
84
+ * Switch the active tenant for a master-key session (no re-auth needed).
85
+ * Returns true if switched, false if not in master mode.
86
+ */
87
+ export function switchTenant(sessionId, newTenant) {
88
+ const session = sessions.get(sessionId);
89
+ if (!session?.masterKey) return false;
90
+ session.tenant = newTenant;
91
+ session.lastActivity = Date.now();
92
+ console.log(`[Auth] Master mode tenant switch: ${newTenant} (session ${sessionId})`);
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * Check if a session is in master key mode.
98
+ */
99
+ export function isMasterMode(sessionId) {
100
+ const session = sessions.get(sessionId);
101
+ return !!(session?.masterKey);
63
102
  }
64
103
 
65
104
  /**
@@ -95,13 +134,17 @@ export function isAuthenticated(sessionId) {
95
134
 
96
135
  /**
97
136
  * Check if a session has been explicitly authenticated via ateam_auth.
98
- * This checks ONLY per-session credentials, ignoring env vars.
137
+ * Checks per-session credentials AND bearer auth overrides.
99
138
  * Used to gate tenant-aware operations — env vars alone are not sufficient
100
139
  * to deploy, update, or read solutions.
101
140
  */
102
141
  export function isExplicitlyAuthenticated(sessionId) {
103
142
  if (!sessionId) return false;
104
- return sessions.has(sessionId);
143
+ // Session has credentials AND they came from ateam_auth (not just seedCredentials)
144
+ const session = sessions.get(sessionId);
145
+ if (session?.authExplicit) return true;
146
+ // Bearer has an active auth override from a previous session's ateam_auth
147
+ return hasBearerAuth(sessionId);
105
148
  }
106
149
 
107
150
  /**
@@ -134,9 +177,69 @@ export function getSessionContext(sessionId) {
134
177
  * Remove session credentials (on disconnect).
135
178
  */
136
179
  export function clearSession(sessionId) {
180
+ sessionBearers.delete(sessionId);
137
181
  sessions.delete(sessionId);
138
182
  }
139
183
 
184
+ // ── Bearer identity functions ──────────────────────────────────────
185
+
186
+ /** Bind a session to its OAuth bearer token. Called from seedCredentials. */
187
+ export function bindSessionBearer(sessionId, bearerToken) {
188
+ sessionBearers.set(sessionId, bearerToken);
189
+ console.log(`[Auth] Bearer bound for session ${sessionId} (bearer: ${bearerToken.substring(0, 25)}...)`);
190
+ }
191
+
192
+ /** Store ateam_auth override for this user (by bearer). Called from tools.js. */
193
+ export function setAuthOverride(sessionId, { tenant, apiKey, apiUrl }) {
194
+ const bearer = sessionBearers.get(sessionId);
195
+ if (!bearer) {
196
+ console.log(`[Auth] WARNING: No bearer bound for session ${sessionId} — override NOT stored. sessionBearers has ${sessionBearers.size} entries.`);
197
+ return;
198
+ }
199
+ authOverrides.set(bearer, { tenant, apiKey, apiUrl: apiUrl || null, updatedAt: Date.now() });
200
+ console.log(`[Auth] Override stored for bearer (tenant: ${tenant}${apiUrl ? ", url: " + apiUrl : ""})`);
201
+ }
202
+
203
+ /** Get ateam_auth override for a bearer token. Returns null if none/expired. */
204
+ export function getAuthOverride(bearerToken) {
205
+ const entry = authOverrides.get(bearerToken);
206
+ if (!entry) return null;
207
+ if (Date.now() - entry.updatedAt > SESSION_TTL) {
208
+ authOverrides.delete(bearerToken);
209
+ return null;
210
+ }
211
+ return { tenant: entry.tenant, apiKey: entry.apiKey, apiUrl: entry.apiUrl || null };
212
+ }
213
+
214
+ /**
215
+ * Get the base URL for a session. Resolution order:
216
+ * 1. Per-session apiUrl (set via ateam_auth url parameter)
217
+ * 2. Bearer auth override apiUrl
218
+ * 3. Environment variable ADAS_API_URL
219
+ * 4. Default: https://api.ateam-ai.com
220
+ */
221
+ export function getBaseUrl(sessionId) {
222
+ // 1. Per-session
223
+ const session = sessionId ? sessions.get(sessionId) : null;
224
+ if (session?.apiUrl) return session.apiUrl;
225
+ // 2. Bearer override
226
+ if (sessionId) {
227
+ const bearer = sessionBearers.get(sessionId);
228
+ if (bearer) {
229
+ const override = getAuthOverride(bearer);
230
+ if (override?.apiUrl) return override.apiUrl;
231
+ }
232
+ }
233
+ // 3/4. Env or default
234
+ return BASE_URL;
235
+ }
236
+
237
+ /** Check if a bearer has an active auth override. */
238
+ export function hasBearerAuth(sessionId) {
239
+ const bearer = sessionBearers.get(sessionId);
240
+ return bearer ? authOverrides.has(bearer) : false;
241
+ }
242
+
140
243
  /**
141
244
  * Sweep expired sessions — removes sessions idle longer than SESSION_TTL.
142
245
  * Returns the number of sessions removed.
@@ -146,12 +249,21 @@ export function sweepStaleSessions() {
146
249
  let swept = 0;
147
250
  for (const [sid, session] of sessions) {
148
251
  if (now - session.lastActivity > SESSION_TTL) {
252
+ sessionBearers.delete(sid);
149
253
  sessions.delete(sid);
150
254
  swept++;
151
255
  }
152
256
  }
153
- if (swept > 0) {
154
- console.log(`[Session] Swept ${swept} stale session(s). ${sessions.size} active.`);
257
+ // Also sweep expired auth overrides
258
+ let overridesSwept = 0;
259
+ for (const [bearer, entry] of authOverrides) {
260
+ if (now - entry.updatedAt > SESSION_TTL) {
261
+ authOverrides.delete(bearer);
262
+ overridesSwept++;
263
+ }
264
+ }
265
+ if (swept > 0 || overridesSwept > 0) {
266
+ console.log(`[Session] Swept ${swept} session(s), ${overridesSwept} override(s). ${sessions.size} active, ${authOverrides.size} overrides.`);
155
267
  }
156
268
  return swept;
157
269
  }
@@ -186,6 +298,17 @@ export function getSessionStats() {
186
298
  }
187
299
 
188
300
  function headers(sessionId) {
301
+ const session = sessionId ? sessions.get(sessionId) : null;
302
+
303
+ // Master mode: use shared secret auth (x-adas-token) instead of API key
304
+ if (session?.masterKey) {
305
+ const h = { "Content-Type": "application/json" };
306
+ h["x-adas-token"] = session.masterKey;
307
+ h["X-ADAS-TENANT"] = session.tenant || "main";
308
+ return h;
309
+ }
310
+
311
+ // Normal mode: API key auth
189
312
  const { tenant, apiKey } = getCredentials(sessionId);
190
313
  const h = { "Content-Type": "application/json" };
191
314
  if (tenant) h["X-ADAS-TENANT"] = tenant;
@@ -232,6 +355,7 @@ async function request(method, path, body, sessionId, opts = {}) {
232
355
  const timeoutMs = opts.timeoutMs || REQUEST_TIMEOUT_MS;
233
356
  const controller = new AbortController();
234
357
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
358
+ const baseUrl = getBaseUrl(sessionId);
235
359
 
236
360
  try {
237
361
  const fetchOpts = {
@@ -243,7 +367,7 @@ async function request(method, path, body, sessionId, opts = {}) {
243
367
  fetchOpts.body = JSON.stringify(body);
244
368
  }
245
369
 
246
- const res = await fetch(`${BASE_URL}${path}`, fetchOpts);
370
+ const res = await fetch(`${baseUrl}${path}`, fetchOpts);
247
371
 
248
372
  if (!res.ok) {
249
373
  const text = await res.text().catch(() => "");
@@ -255,18 +379,18 @@ async function request(method, path, body, sessionId, opts = {}) {
255
379
  if (err.name === "AbortError") {
256
380
  throw new Error(
257
381
  `A-Team API timeout: ${method} ${path} did not respond within ${timeoutMs / 1000}s.\n` +
258
- `Hint: The A-Team API at ${BASE_URL} may be down. Check https://api.ateam-ai.com/health`
382
+ `Hint: The A-Team API at ${baseUrl} may be down. Check ${baseUrl}/health`
259
383
  );
260
384
  }
261
385
  if (err.cause?.code === "ECONNREFUSED") {
262
386
  throw new Error(
263
- `Cannot connect to A-Team API at ${BASE_URL}.\n` +
264
- `Hint: The service may be down. Check https://api.ateam-ai.com/health`
387
+ `Cannot connect to A-Team API at ${baseUrl}.\n` +
388
+ `Hint: The service may be down. Check ${baseUrl}/health`
265
389
  );
266
390
  }
267
391
  if (err.cause?.code === "ENOTFOUND") {
268
392
  throw new Error(
269
- `Cannot resolve A-Team API host: ${BASE_URL}.\n` +
393
+ `Cannot resolve A-Team API host: ${baseUrl}.\n` +
270
394
  `Hint: Check your internet connection and ADAS_API_URL setting.`
271
395
  );
272
396
  }
@@ -291,3 +415,36 @@ export async function patch(path, body, sessionId, opts) {
291
415
  export async function del(path, sessionId, opts) {
292
416
  return request("DELETE", path, undefined, sessionId, opts);
293
417
  }
418
+
419
+ /**
420
+ * List all active tenants from Core API (requires master key).
421
+ * Calls Core directly (not through Builder) using shared secret auth.
422
+ */
423
+ export async function listTenants(sessionId) {
424
+ const session = sessionId ? sessions.get(sessionId) : null;
425
+ if (!session?.masterKey) throw new Error("listTenants requires master key auth");
426
+
427
+ // Resolve Core URL: env var > derive from Builder URL > fallback
428
+ const coreUrl = CORE_URL || BASE_URL.replace(/:\d+$/, ":4000");
429
+ const controller = new AbortController();
430
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
431
+
432
+ try {
433
+ const res = await fetch(`${coreUrl}/api/tenants/list`, {
434
+ method: "GET",
435
+ headers: {
436
+ "Content-Type": "application/json",
437
+ "x-adas-token": session.masterKey,
438
+ },
439
+ signal: controller.signal,
440
+ });
441
+ if (!res.ok) {
442
+ const text = await res.text().catch(() => "");
443
+ throw new Error(`Core API error: GET /api/tenants/list returned ${res.status} — ${text}`);
444
+ }
445
+ const data = await res.json();
446
+ return data.tenants || [];
447
+ } finally {
448
+ clearTimeout(timeout);
449
+ }
450
+ }
package/src/http.js CHANGED
@@ -26,6 +26,7 @@ import { createServer } from "./server.js";
26
26
  import {
27
27
  clearSession, setSessionCredentials, parseApiKey,
28
28
  startSessionSweeper, getSessionStats, sweepStaleSessions,
29
+ bindSessionBearer, getAuthOverride,
29
30
  } from "./api.js";
30
31
  import { mountOAuth } from "./oauth.js";
31
32
 
@@ -197,8 +198,13 @@ export function startHttpServer(port = 3100) {
197
198
  // Reuse existing session — seed credentials if Bearer token present
198
199
  transport = transports[sessionId];
199
200
  seedCredentials(req, sessionId);
200
- } else if (!sessionId && isInitializeRequest(req.body)) {
201
- // New session generate ID upfront so we can bind it to the server
201
+ } else if (isInitializeRequest(req.body)) {
202
+ // New session (or stale session after server restart) create fresh session.
203
+ // Accept initialize requests even if they carry a stale mcp-session-id.
204
+ if (sessionId) {
205
+ console.log(`[HTTP] Stale session ${sessionId} — creating new session (server was restarted)`);
206
+ }
207
+
202
208
  const newSessionId = randomUUID();
203
209
 
204
210
  // Seed credentials from OAuth Bearer token before server starts
@@ -224,6 +230,16 @@ export function startHttpServer(port = 3100) {
224
230
  await server.connect(transport);
225
231
  await transport.handleRequest(req, res, req.body);
226
232
  return;
233
+ } else if (sessionId && !transports[sessionId]) {
234
+ // Stale session (non-initialize request) — tell client to re-initialize.
235
+ // This happens after server restarts when the client still has the old session ID.
236
+ console.log(`[HTTP] Stale session ${sessionId} — returning 400 to trigger re-init`);
237
+ res.status(400).json({
238
+ jsonrpc: "2.0",
239
+ error: { code: -32600, message: "Session expired. Please re-initialize." },
240
+ id: req.body?.id || null,
241
+ });
242
+ return;
227
243
  } else {
228
244
  res.status(400).json({
229
245
  jsonrpc: "2.0",
@@ -326,13 +342,27 @@ function getNewestToken() {
326
342
  }
327
343
 
328
344
  /**
329
- * If the request has a validated Bearer token (set by requireBearerAuth),
330
- * auto-seed session credentials so the user doesn't need to call ateam_auth.
345
+ * Seed session credentials from the OAuth bearer token.
346
+ *
347
+ * The bearer IS the user's API key (set during OAuth authorization).
348
+ * If the user previously called ateam_auth to override (e.g., switch tenants),
349
+ * that override is stored per bearer and takes priority here.
331
350
  */
332
351
  function seedCredentials(req, sessionId) {
333
352
  const token = req.auth?.token;
334
353
  if (!token) return;
335
354
 
355
+ // Track bearer → session (persistent actor identity)
356
+ bindSessionBearer(sessionId, token);
357
+
358
+ // Check for ateam_auth override for this bearer
359
+ const override = getAuthOverride(token);
360
+ if (override) {
361
+ setSessionCredentials(sessionId, { ...override, explicit: true });
362
+ return;
363
+ }
364
+
365
+ // Default: use the bearer token itself as credentials
336
366
  const parsed = parseApiKey(token);
337
367
  if (parsed.isValid) {
338
368
  setSessionCredentials(sessionId, { tenant: parsed.tenant, apiKey: token });
package/src/tools.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  get, post, patch, del,
13
13
  setSessionCredentials, isAuthenticated, isExplicitlyAuthenticated,
14
14
  getCredentials, parseApiKey, touchSession, getSessionContext,
15
+ setAuthOverride, switchTenant, isMasterMode, listTenants,
15
16
  } from "./api.js";
16
17
 
17
18
  // ─── Tool definitions ───────────────────────────────────────────────
@@ -35,7 +36,7 @@ export const tools = [
35
36
  name: "ateam_auth",
36
37
  core: true,
37
38
  description:
38
- "Authenticate with A-Team. Required before any tenant-aware operation (reading solutions, deploying, testing, etc.). The user can get their API key at https://mcp.ateam-ai.com/get-api-key. Only global endpoints (spec, examples, validate) work without auth. IMPORTANT: Even if environment variables (ADAS_API_KEY) are configured, you MUST call ateam_auth explicitly — env vars alone are not sufficient.",
39
+ "Authenticate with A-Team. Required before any tenant-aware operation (reading solutions, deploying, testing, etc.). The user can get their API key at https://mcp.ateam-ai.com/get-api-key. Only global endpoints (spec, examples, validate) work without auth. IMPORTANT: Even if environment variables (ADAS_API_KEY) are configured, you MUST call ateam_auth explicitly — env vars alone are not sufficient. For cross-tenant admin operations, use master_key instead of api_key.",
39
40
  inputSchema: {
40
41
  type: "object",
41
42
  properties: {
@@ -43,12 +44,19 @@ export const tools = [
43
44
  type: "string",
44
45
  description: "Your A-Team API key (e.g., adas_xxxxx)",
45
46
  },
47
+ master_key: {
48
+ type: "string",
49
+ description: "Master key for cross-tenant operations. Authenticates across ALL tenants without per-tenant API keys. Requires tenant parameter.",
50
+ },
46
51
  tenant: {
47
52
  type: "string",
48
- description: "Tenant name (e.g., dev, main). Optional if your key has the format adas_<tenant>_<hex> the tenant is auto-extracted.",
53
+ description: "Tenant name (e.g., dev, main). Optional with api_key if format is adas_<tenant>_<hex>. REQUIRED with master_key.",
54
+ },
55
+ url: {
56
+ type: "string",
57
+ description: "Optional API URL override (e.g., https://dev-api.ateam-ai.com). Use this to target a different environment without restarting the MCP server.",
49
58
  },
50
59
  },
51
- required: ["api_key"],
52
60
  },
53
61
  },
54
62
  {
@@ -61,9 +69,9 @@ export const tools = [
61
69
  properties: {
62
70
  topic: {
63
71
  type: "string",
64
- enum: ["overview", "skill", "solution", "enums"],
72
+ enum: ["overview", "skill", "solution", "enums", "connector-multi-user"],
65
73
  description:
66
- "What to fetch: 'overview' = API overview + endpoints, 'skill' = full skill spec, 'solution' = full solution spec, 'enums' = all enum values",
74
+ "What to fetch: 'overview' = API overview + endpoints, 'skill' = full skill spec, 'solution' = full solution spec, 'enums' = all enum values, 'connector-multi-user' = multi-user connector guide (actor isolation, zod gotcha, complete examples)",
67
75
  },
68
76
  },
69
77
  required: ["topic"],
@@ -123,6 +131,10 @@ export const tools = [
123
131
  type: "object",
124
132
  description: "Optional: connector source code files. Key = connector id, value = array of {path, content}.",
125
133
  },
134
+ github: {
135
+ type: "boolean",
136
+ description: "Optional: if true, pull connector source code from the solution's GitHub repo instead of requiring mcp_store. Use this after the first deploy (which creates the repo). Cannot be used on first deploy.",
137
+ },
126
138
  test_message: {
127
139
  type: "string",
128
140
  description: "Optional: send a test message after deployment to verify the skill works. Returns the full execution result.",
@@ -164,6 +176,63 @@ export const tools = [
164
176
  required: ["solution_id", "skill_id", "message"],
165
177
  },
166
178
  },
179
+ {
180
+ name: "ateam_test_pipeline",
181
+ core: true,
182
+ description:
183
+ "Test the decision pipeline (intent detection → planning) for a skill WITHOUT executing tools. Returns intent classification, first planned action, and timing. Use this to debug why a skill classifies intent incorrectly or plans the wrong action.",
184
+ inputSchema: {
185
+ type: "object",
186
+ properties: {
187
+ solution_id: {
188
+ type: "string",
189
+ description: "The solution ID",
190
+ },
191
+ skill_id: {
192
+ type: "string",
193
+ description: "The skill ID to test",
194
+ },
195
+ message: {
196
+ type: "string",
197
+ description: "The test message to classify and plan for",
198
+ },
199
+ },
200
+ required: ["solution_id", "skill_id", "message"],
201
+ },
202
+ },
203
+ {
204
+ name: "ateam_test_voice",
205
+ core: true,
206
+ description:
207
+ "Simulate a voice conversation with a deployed solution. Runs the full voice pipeline (session → caller verification → prompt → skill dispatch → response) using text instead of audio. Returns each turn with bot response, verification status, tool calls, and entities. Use this to test voice-enabled solutions end-to-end without making a phone call.",
208
+ inputSchema: {
209
+ type: "object",
210
+ properties: {
211
+ solution_id: {
212
+ type: "string",
213
+ description: "The solution ID",
214
+ },
215
+ messages: {
216
+ type: "array",
217
+ items: { type: "string" },
218
+ description: "Array of user messages to send sequentially (simulates a multi-turn phone conversation)",
219
+ },
220
+ phone_number: {
221
+ type: "string",
222
+ description: "Optional: simulated caller phone number (e.g., '+14155551234'). If the number is in the solution's known phones list, the caller is auto-verified.",
223
+ },
224
+ skill_slug: {
225
+ type: "string",
226
+ description: "Optional: target a specific skill by slug instead of using voice routing.",
227
+ },
228
+ timeout_ms: {
229
+ type: "number",
230
+ description: "Optional: max wait time per skill execution in milliseconds (default: 60000).",
231
+ },
232
+ },
233
+ required: ["solution_id", "messages"],
234
+ },
235
+ },
167
236
  {
168
237
  name: "ateam_patch",
169
238
  core: true,
@@ -249,6 +318,26 @@ export const tools = [
249
318
  required: ["solution_id"],
250
319
  },
251
320
  },
321
+ {
322
+ name: "ateam_delete_connector",
323
+ core: true,
324
+ description:
325
+ "Remove a connector from a deployed solution. Stops and deletes it from A-Team Core, removes references from the solution definition (grants, platform_connectors) and skill definitions (connectors array), and cleans up mcp-store files.",
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: {
329
+ solution_id: {
330
+ type: "string",
331
+ description: "The solution ID (e.g. 'smart-home-assistant')",
332
+ },
333
+ connector_id: {
334
+ type: "string",
335
+ description: "The connector ID to remove (e.g. 'device-mock-mcp')",
336
+ },
337
+ },
338
+ required: ["solution_id", "connector_id"],
339
+ },
340
+ },
252
341
 
253
342
  // ═══════════════════════════════════════════════════════════════════
254
343
  // ADVANCED TOOLS — hidden from tools/list, still callable by name
@@ -535,19 +624,19 @@ export const tools = [
535
624
  },
536
625
  {
537
626
  name: "ateam_get_connector_source",
538
- core: false,
627
+ core: true,
539
628
  description:
540
- "Read the source code of a connector's MCP server. Returns the files that make up the connector implementation. (Advanced.)",
629
+ `Read the source code files of a deployed MCP connector. Returns all files (server.js, package.json, etc.) stored in the mcp_store for this connector. Use this BEFORE patching or rewriting a connector — always read the current code first so you can make surgical fixes instead of blind full rewrites.`,
541
630
  inputSchema: {
542
631
  type: "object",
543
632
  properties: {
544
633
  solution_id: {
545
634
  type: "string",
546
- description: "The solution ID",
635
+ description: "The solution ID (e.g. 'smart-home-assistant')",
547
636
  },
548
637
  connector_id: {
549
638
  type: "string",
550
- description: "The connector ID",
639
+ description: "The connector ID to read (e.g. 'home-assistant-mcp')",
551
640
  },
552
641
  },
553
642
  required: ["solution_id", "connector_id"],
@@ -597,6 +686,181 @@ export const tools = [
597
686
  required: ["solution_id"],
598
687
  },
599
688
  },
689
+
690
+ // ═══════════════════════════════════════════════════════════════════
691
+ // GITHUB TOOLS — version control for solutions
692
+ // ═══════════════════════════════════════════════════════════════════
693
+
694
+ {
695
+ name: "ateam_github_push",
696
+ core: true,
697
+ description:
698
+ "Push the current deployed solution to GitHub. Auto-creates the repo on first use. Commits the full bundle (solution + skills + connector source) atomically. Use after ateam_build_and_run to version your solution, or anytime you want to snapshot the current state.",
699
+ inputSchema: {
700
+ type: "object",
701
+ properties: {
702
+ solution_id: {
703
+ type: "string",
704
+ description: "The solution ID (e.g. 'smart-home-assistant')",
705
+ },
706
+ message: {
707
+ type: "string",
708
+ description: "Optional commit message (default: 'Deploy <solution_id>')",
709
+ },
710
+ },
711
+ required: ["solution_id"],
712
+ },
713
+ },
714
+ {
715
+ name: "ateam_github_pull",
716
+ core: true,
717
+ description:
718
+ "Deploy a solution FROM its GitHub repo. Reads .ateam/export.json + connector source from the repo and feeds it into the deploy pipeline. Use this to restore a previous version or deploy from GitHub as the source of truth.",
719
+ inputSchema: {
720
+ type: "object",
721
+ properties: {
722
+ solution_id: {
723
+ type: "string",
724
+ description: "The solution ID to pull and deploy from GitHub",
725
+ },
726
+ },
727
+ required: ["solution_id"],
728
+ },
729
+ },
730
+ {
731
+ name: "ateam_github_status",
732
+ core: true,
733
+ description:
734
+ "Check if a solution has a GitHub repo, its URL, and the latest commit. Use this to verify GitHub integration is working for a solution.",
735
+ inputSchema: {
736
+ type: "object",
737
+ properties: {
738
+ solution_id: {
739
+ type: "string",
740
+ description: "The solution ID",
741
+ },
742
+ },
743
+ required: ["solution_id"],
744
+ },
745
+ },
746
+ {
747
+ name: "ateam_github_read",
748
+ core: true,
749
+ description:
750
+ "Read any file from a solution's GitHub repo. Returns the file content. Use this to read connector source code, skill definitions, or any versioned file. Great for reviewing previous versions or understanding what's in the repo.",
751
+ inputSchema: {
752
+ type: "object",
753
+ properties: {
754
+ solution_id: {
755
+ type: "string",
756
+ description: "The solution ID",
757
+ },
758
+ path: {
759
+ type: "string",
760
+ description: "File path in the repo (e.g. 'connectors/home-assistant-mcp/server.js', 'solution.json', 'skills/order-support/skill.json')",
761
+ },
762
+ },
763
+ required: ["solution_id", "path"],
764
+ },
765
+ },
766
+ {
767
+ name: "ateam_github_patch",
768
+ core: true,
769
+ description:
770
+ "Edit a specific file in the solution's GitHub repo and commit. Creates the file if it doesn't exist. Use this to make surgical fixes to connector source code, update skill definitions, or add new files directly in the repo.",
771
+ inputSchema: {
772
+ type: "object",
773
+ properties: {
774
+ solution_id: {
775
+ type: "string",
776
+ description: "The solution ID",
777
+ },
778
+ path: {
779
+ type: "string",
780
+ description: "File path to create/update (e.g. 'connectors/home-assistant-mcp/server.js')",
781
+ },
782
+ content: {
783
+ type: "string",
784
+ description: "The full file content to write",
785
+ },
786
+ message: {
787
+ type: "string",
788
+ description: "Optional commit message (default: 'Update <path>')",
789
+ },
790
+ },
791
+ required: ["solution_id", "path", "content"],
792
+ },
793
+ },
794
+ {
795
+ name: "ateam_github_log",
796
+ core: true,
797
+ description:
798
+ "View commit history for a solution's GitHub repo. Shows recent commits with messages, SHAs, timestamps, and links. Use this to see what changes have been made and when.",
799
+ inputSchema: {
800
+ type: "object",
801
+ properties: {
802
+ solution_id: {
803
+ type: "string",
804
+ description: "The solution ID",
805
+ },
806
+ limit: {
807
+ type: "number",
808
+ description: "Max commits to return (default: 10)",
809
+ },
810
+ },
811
+ required: ["solution_id"],
812
+ },
813
+ },
814
+
815
+ // ═══════════════════════════════════════════════════════════════════
816
+ // MASTER KEY TOOLS — cross-tenant bulk operations (master key only)
817
+ // ═══════════════════════════════════════════════════════════════════
818
+
819
+ {
820
+ name: "ateam_redeploy",
821
+ core: true,
822
+ description:
823
+ "Re-deploy all skills in a solution without changing anything. Regenerates MCP servers and pushes to A-Team Core. Use after connector restarts, Core hiccups, or when you just need a fresh deploy without modifying the solution/skill definitions.",
824
+ inputSchema: {
825
+ type: "object",
826
+ properties: {
827
+ solution_id: {
828
+ type: "string",
829
+ description: "The solution ID to redeploy (e.g. 'smart-home-assistant')",
830
+ },
831
+ },
832
+ required: ["solution_id"],
833
+ },
834
+ },
835
+ {
836
+ name: "ateam_status_all",
837
+ core: true,
838
+ description:
839
+ "Show GitHub sync status for ALL tenants and solutions in one call. Requires master key authentication. Returns a summary table of every tenant's solutions with their GitHub sync state.",
840
+ inputSchema: {
841
+ type: "object",
842
+ properties: {},
843
+ },
844
+ },
845
+ {
846
+ name: "ateam_sync_all",
847
+ core: true,
848
+ description:
849
+ "Sync ALL tenants: push Builder FS → GitHub, then pull GitHub → Core MongoDB. Requires master key authentication. Returns a summary table with results for each tenant/solution.",
850
+ inputSchema: {
851
+ type: "object",
852
+ properties: {
853
+ push_only: {
854
+ type: "boolean",
855
+ description: "Only push to GitHub (skip pull to Core). Default: false (full sync).",
856
+ },
857
+ pull_only: {
858
+ type: "boolean",
859
+ description: "Only pull from GitHub to Core (skip push). Default: false (full sync).",
860
+ },
861
+ },
862
+ },
863
+ },
600
864
  ];
601
865
 
602
866
  /**
@@ -612,6 +876,7 @@ const SPEC_PATHS = {
612
876
  skill: "/spec/skill",
613
877
  solution: "/spec/solution",
614
878
  enums: "/spec/enums",
879
+ "connector-multi-user": "/spec/multi-user-connector",
615
880
  };
616
881
 
617
882
  const EXAMPLE_PATHS = {
@@ -637,17 +902,31 @@ const TENANT_TOOLS = new Set([
637
902
  "ateam_update",
638
903
  "ateam_redeploy",
639
904
  "ateam_delete_solution",
905
+ "ateam_delete_connector",
906
+ "ateam_redeploy",
640
907
  "ateam_solution_chat",
641
908
  // Read operations (tenant-specific data)
642
909
  "ateam_list_solutions",
643
910
  "ateam_get_solution",
644
911
  "ateam_get_execution_logs",
645
912
  "ateam_test_skill",
913
+ "ateam_test_pipeline",
914
+ "ateam_test_voice",
646
915
  "ateam_test_status",
647
916
  "ateam_test_abort",
648
917
  "ateam_get_connector_source",
649
918
  "ateam_get_metrics",
650
919
  "ateam_diff",
920
+ // GitHub operations
921
+ "ateam_github_push",
922
+ "ateam_github_pull",
923
+ "ateam_github_status",
924
+ "ateam_github_read",
925
+ "ateam_github_patch",
926
+ "ateam_github_log",
927
+ // Master key bulk operations
928
+ "ateam_status_all",
929
+ "ateam_sync_all",
651
930
  ]);
652
931
 
653
932
  /** Small delay helper */
@@ -676,12 +955,13 @@ const handlers = {
676
955
  { name: "Enterprise Compliance Platform", description: "Approval flows, audit logs, policy enforcement" },
677
956
  ],
678
957
  developer_loop: {
679
- _note: "This is the recommended build loop. Only 4 steps from definition to running skill.",
958
+ _note: "This is the recommended build loop. 5 steps from definition to running skill with GitHub version control.",
680
959
  steps: [
681
960
  { step: 1, action: "Learn", description: "Get the spec and study examples", tools: ["ateam_get_spec", "ateam_get_examples"] },
682
- { step: 2, action: "Build & Run", description: "Define your solution + skills, then validate, deploy, and health-check in one call. Optionally include a test_message to verify it works immediately.", tools: ["ateam_build_and_run"] },
683
- { step: 3, action: "Test", description: "Send test messages to your deployed skill and see the full execution trace.", tools: ["ateam_test_skill"] },
684
- { step: 4, action: "Iterate", description: "Patch the skill (update + redeploy + re-test in one call), repeat until satisfied.", tools: ["ateam_patch"] },
961
+ { step: 2, action: "Build & Run", description: "Define your solution + skills + connector code, then validate, deploy, and health-check in one call. Include mcp_store with connector source code on the first deploy.", tools: ["ateam_build_and_run"] },
962
+ { step: 3, action: "Version", description: "Every deploy auto-pushes to GitHub. The repo (tenant--solution-id) is the source of truth for connector code.", tools: ["ateam_github_status", "ateam_github_log"] },
963
+ { step: 4, action: "Iterate", description: "Edit connector code via ateam_github_patch, then redeploy with ateam_build_and_run(github:true). For skill definition changes (intents, tools, policy), use ateam_patch.", tools: ["ateam_github_patch", "ateam_build_and_run", "ateam_patch"] },
964
+ { step: 5, action: "Test & Debug", description: "Test the decision pipeline or full execution, then diagnose with logs and metrics. For voice-enabled solutions, use ateam_test_voice to simulate phone conversations.", tools: ["ateam_test_pipeline", "ateam_test_skill", "ateam_test_voice", "ateam_get_execution_logs", "ateam_get_metrics"] },
685
965
  ],
686
966
  },
687
967
  first_questions: [
@@ -690,6 +970,27 @@ const handlers = {
690
970
  { id: "systems", question: "Which systems should the Team connect to?", type: "multi_select", options: ["slack", "email", "zendesk", "shopify", "jira", "postgres", "custom_api", "none"] },
691
971
  { id: "security", question: "What environment constraints?", type: "enum", options: ["sandbox", "controlled", "regulated"] },
692
972
  ],
973
+ github_tools: {
974
+ _note: "Version control for solutions. Every deploy auto-pushes to GitHub. The repo is the source of truth for connector code.",
975
+ tools: ["ateam_github_push", "ateam_github_pull", "ateam_github_status", "ateam_github_read", "ateam_github_patch", "ateam_github_log"],
976
+ repo_structure: {
977
+ "solution.json": "Full solution definition",
978
+ "skills/{skill-id}/skill.json": "Individual skill definitions",
979
+ "connectors/{connector-id}/server.js": "Connector MCP server code",
980
+ "connectors/{connector-id}/package.json": "Connector dependencies",
981
+ },
982
+ iteration_workflow: {
983
+ code_changes: "ateam_github_patch (edit connector files) → ateam_build_and_run(github:true) (redeploy from repo)",
984
+ definition_changes: "ateam_patch (edit skill/solution definitions directly in Builder)",
985
+ first_deploy: "Must include mcp_store — this creates the GitHub repo",
986
+ },
987
+ when_to_use_what: {
988
+ ateam_github_patch: "Edit connector source code (server.js, utils, package.json, UI assets)",
989
+ ateam_patch: "Edit skill definitions (intents, tools, policy) or solution definitions (grants, handoffs, routing)",
990
+ "ateam_build_and_run(github:true)": "Redeploy solution pulling latest connector code from GitHub",
991
+ "ateam_build_and_run(mcp_store)": "First deploy or when you want to pass connector code inline",
992
+ },
993
+ },
693
994
  advanced_tools: {
694
995
  _note: "These tools are available but hidden from the default tool list. Call them by name when you need fine-grained control.",
695
996
  debugging: ["ateam_get_execution_logs", "ateam_get_metrics", "ateam_diff", "ateam_get_connector_source"],
@@ -728,7 +1029,8 @@ const handlers = {
728
1029
  always: [
729
1030
  "Explain Skill vs Solution vs Connector before building",
730
1031
  "Use ateam_build_and_run for the full lifecycle (validates automatically)",
731
- "Use ateam_patch for iterations (updates + redeploys automatically)",
1032
+ "Use ateam_patch for skill/solution definition changes (updates + redeploys automatically)",
1033
+ "Use ateam_github_patch + ateam_build_and_run(github:true) for connector code changes after first deploy",
732
1034
  "Study the connector example (ateam_get_examples type='connector') before writing connector code",
733
1035
  "Ask discovery questions if goal unclear",
734
1036
  ],
@@ -741,21 +1043,52 @@ const handlers = {
741
1043
  },
742
1044
  }),
743
1045
 
744
- ateam_auth: async ({ api_key, tenant }, sessionId) => {
1046
+ ateam_auth: async ({ api_key, master_key, tenant, url }, sessionId) => {
1047
+ // Master key mode: cross-tenant auth using shared secret
1048
+ if (master_key) {
1049
+ if (!tenant) {
1050
+ return { ok: false, message: "Master key requires a tenant parameter. Specify which tenant to operate on." };
1051
+ }
1052
+ const apiUrl = url ? url.replace(/\/+$/, "") : undefined;
1053
+ setSessionCredentials(sessionId, { tenant, apiKey: null, apiUrl, explicit: true, masterKey: master_key });
1054
+ // Verify by listing solutions
1055
+ try {
1056
+ const result = await get("/deploy/solutions", sessionId);
1057
+ const urlNote = apiUrl ? ` (via ${apiUrl})` : "";
1058
+ return {
1059
+ ok: true,
1060
+ tenant,
1061
+ masterMode: true,
1062
+ message: `Master key authenticated to tenant "${tenant}"${urlNote}. ${result.solutions?.length || 0} solution(s) found. Use tenant parameter on any tool to switch tenants without re-auth.`,
1063
+ };
1064
+ } catch (err) {
1065
+ return { ok: false, tenant, message: `Master key auth failed: ${err.message}` };
1066
+ }
1067
+ }
1068
+
1069
+ // Normal API key mode
1070
+ if (!api_key) {
1071
+ return { ok: false, message: "Provide either api_key or master_key." };
1072
+ }
745
1073
  // Auto-extract tenant from key if not provided
746
1074
  let resolvedTenant = tenant;
747
1075
  if (!resolvedTenant) {
748
1076
  const parsed = parseApiKey(api_key);
749
1077
  resolvedTenant = parsed.tenant || "main";
750
1078
  }
751
- setSessionCredentials(sessionId, { tenant: resolvedTenant, apiKey: api_key });
1079
+ // Normalize URL: strip trailing slash
1080
+ const apiUrl = url ? url.replace(/\/+$/, "") : undefined;
1081
+ setSessionCredentials(sessionId, { tenant: resolvedTenant, apiKey: api_key, apiUrl, explicit: true });
1082
+ // Persist override per bearer (survives session changes)
1083
+ setAuthOverride(sessionId, { tenant: resolvedTenant, apiKey: api_key, apiUrl });
752
1084
  // Verify the key works by listing solutions
753
1085
  try {
754
1086
  const result = await get("/deploy/solutions", sessionId);
1087
+ const urlNote = apiUrl ? ` (via ${apiUrl})` : "";
755
1088
  return {
756
1089
  ok: true,
757
1090
  tenant: resolvedTenant,
758
- message: `Authenticated to tenant "${resolvedTenant}". ${result.solutions?.length || 0} solution(s) found.`,
1091
+ message: `Authenticated to tenant "${resolvedTenant}"${urlNote}. ${result.solutions?.length || 0} solution(s) found.`,
759
1092
  };
760
1093
  } catch (err) {
761
1094
  return {
@@ -776,13 +1109,49 @@ const handlers = {
776
1109
  // Validates → Deploys → Health-checks → Optionally tests
777
1110
  // One call replaces: validate_solution + deploy_solution + get_solution(health)
778
1111
 
779
- ateam_build_and_run: async ({ solution, skills, connectors, mcp_store, test_message, test_skill_id }, sid) => {
1112
+ ateam_build_and_run: async ({ solution, skills, connectors, mcp_store, github, test_message, test_skill_id }, sid) => {
780
1113
  const phases = [];
781
1114
 
1115
+ // Phase 0: GitHub pull (if github:true — pull connector source from repo)
1116
+ let effectiveMcpStore = mcp_store;
1117
+ if (github && !mcp_store) {
1118
+ try {
1119
+ const pullResult = await post(
1120
+ `/deploy/solutions/${solution.id}/github/pull-connectors`,
1121
+ {},
1122
+ sid,
1123
+ { timeoutMs: 30_000 },
1124
+ );
1125
+ if (!pullResult.ok) {
1126
+ return {
1127
+ ok: false,
1128
+ phase: "github_pull",
1129
+ error: pullResult.error || "Failed to pull connectors from GitHub",
1130
+ hint: pullResult.hint || "Deploy the solution first (with mcp_store) to auto-create the GitHub repo.",
1131
+ message: "Cannot pull connector code from GitHub. The repo may not exist yet — deploy with mcp_store first.",
1132
+ };
1133
+ }
1134
+ effectiveMcpStore = pullResult.mcp_store;
1135
+ phases.push({
1136
+ phase: "github_pull",
1137
+ status: "done",
1138
+ connectors_found: pullResult.connectors_found || 0,
1139
+ files_loaded: pullResult.files_loaded || 0,
1140
+ });
1141
+ } catch (err) {
1142
+ return {
1143
+ ok: false,
1144
+ phase: "github_pull",
1145
+ error: err.message,
1146
+ message: "Failed to pull connector code from GitHub. The repo may not exist yet — deploy with mcp_store first.",
1147
+ };
1148
+ }
1149
+ }
1150
+
782
1151
  // Phase 1: Validate
783
1152
  let validation;
784
1153
  try {
785
- validation = await post("/validate/solution", { solution, skills }, sid);
1154
+ validation = await post("/validate/solution", { solution, skills, connectors, mcp_store: effectiveMcpStore }, sid, { timeoutMs: 120_000 });
786
1155
  phases.push({ phase: "validate", status: "done" });
787
1156
  } catch (err) {
788
1157
  return {
@@ -808,7 +1177,7 @@ const handlers = {
808
1177
  // Phase 2: Deploy
809
1178
  let deploy;
810
1179
  try {
811
- deploy = await post("/deploy/solution", { solution, skills, connectors, mcp_store }, sid);
1180
+ deploy = await post("/deploy/solution", { solution, skills, connectors, mcp_store: effectiveMcpStore }, sid, { timeoutMs: 300_000 });
812
1181
  phases.push({ phase: "deploy", status: deploy.ok ? "done" : "failed" });
813
1182
  } catch (err) {
814
1183
  return {
@@ -863,6 +1232,25 @@ const handlers = {
863
1232
  }
864
1233
  }
865
1234
 
1235
+ // Phase 5: GitHub push (auto — non-blocking, failures don't fail the deploy)
1236
+ let github_result;
1237
+ try {
1238
+ github_result = await post(
1239
+ `/deploy/solutions/${solution.id}/github/push`,
1240
+ { message: `Deploy: ${solution.name || solution.id}` },
1241
+ sid,
1242
+ { timeoutMs: 60_000 },
1243
+ );
1244
+ phases.push({
1245
+ phase: "github",
1246
+ status: github_result.skipped ? "skipped" : "done",
1247
+ ...(github_result.repo_url && { repo_url: github_result.repo_url }),
1248
+ });
1249
+ } catch (err) {
1250
+ github_result = { error: err.message };
1251
+ phases.push({ phase: "github", status: "error", error: err.message });
1252
+ }
1253
+
866
1254
  return {
867
1255
  ok: true,
868
1256
  solution_id: solution.id,
@@ -875,6 +1263,7 @@ const handlers = {
875
1263
  },
876
1264
  health,
877
1265
  ...(test_result && { test_result }),
1266
+ ...(github_result && !github_result.error && !github_result.skipped && { github: github_result }),
878
1267
  ...(validation.warnings?.length > 0 && { validation_warnings: validation.warnings }),
879
1268
  };
880
1269
  },
@@ -956,8 +1345,8 @@ const handlers = {
956
1345
 
957
1346
  ateam_validate_skill: async ({ skill }, sid) => post("/validate/skill", { skill }, sid),
958
1347
 
959
- ateam_validate_solution: async ({ solution, skills }, sid) =>
960
- post("/validate/solution", { solution, skills }, sid),
1348
+ ateam_validate_solution: async ({ solution, skills, connectors, mcp_store }, sid) =>
1349
+ post("/validate/solution", { solution, skills, connectors, mcp_store }, sid),
961
1350
 
962
1351
  ateam_deploy_solution: async ({ solution, skills, connectors, mcp_store }, sid) =>
963
1352
  post("/deploy/solution", { solution, skills, connectors, mcp_store }, sid),
@@ -1042,6 +1431,20 @@ const handlers = {
1042
1431
  return post(`/deploy/solutions/${solution_id}/skills/${skill_id}/test`, body, sid, { timeoutMs });
1043
1432
  },
1044
1433
 
1434
+ ateam_test_pipeline: async ({ solution_id, skill_id, message }, sid) =>
1435
+ post(`/deploy/solutions/${solution_id}/skills/${skill_id}/test-pipeline`, { message }, sid, { timeoutMs: 30_000 }),
1436
+
1437
+ ateam_test_voice: async ({ solution_id, messages, phone_number, skill_slug, timeout_ms }, sid) => {
1438
+ const body = { messages };
1439
+ if (phone_number) body.phone_number = phone_number;
1440
+ if (skill_slug) body.skill_slug = skill_slug;
1441
+ if (timeout_ms) body.timeout_ms = timeout_ms;
1442
+ // Timeout scales with message count — each turn may invoke skills
1443
+ const perTurnMs = timeout_ms || 60_000;
1444
+ const timeoutTotal = Math.min(perTurnMs * messages.length + 30_000, 600_000);
1445
+ return post(`/deploy/voice-test`, body, sid, { timeoutMs: timeoutTotal });
1446
+ },
1447
+
1045
1448
  ateam_test_status: async ({ solution_id, skill_id, job_id }, sid) =>
1046
1449
  get(`/deploy/solutions/${solution_id}/skills/${skill_id}/test/${job_id}`, sid),
1047
1450
 
@@ -1064,8 +1467,130 @@ const handlers = {
1064
1467
  return get(`/deploy/solutions/${solution_id}/diff${qs}`, sid);
1065
1468
  },
1066
1469
 
1470
+ // ─── GitHub tools ──────────────────────────────────────────────────
1471
+
1472
+ ateam_github_push: async ({ solution_id, message }, sid) =>
1473
+ post(`/deploy/solutions/${solution_id}/github/push`, { message }, sid, { timeoutMs: 60_000 }),
1474
+
1475
+ ateam_github_pull: async ({ solution_id }, sid) =>
1476
+ post(`/deploy/solutions/${solution_id}/github/pull`, {}, sid, { timeoutMs: 300_000 }),
1477
+
1478
+ ateam_github_status: async ({ solution_id }, sid) =>
1479
+ get(`/deploy/solutions/${solution_id}/github/status`, sid),
1480
+
1481
+ ateam_github_read: async ({ solution_id, path: filePath }, sid) =>
1482
+ get(`/deploy/solutions/${solution_id}/github/read?path=${encodeURIComponent(filePath)}`, sid),
1483
+
1484
+ ateam_github_patch: async ({ solution_id, path: filePath, content, message }, sid) =>
1485
+ post(`/deploy/solutions/${solution_id}/github/patch`, { path: filePath, content, message }, sid),
1486
+
1487
+ ateam_github_log: async ({ solution_id, limit }, sid) => {
1488
+ const qs = limit ? `?limit=${limit}` : "";
1489
+ return get(`/deploy/solutions/${solution_id}/github/log${qs}`, sid);
1490
+ },
1491
+
1067
1492
  ateam_delete_solution: async ({ solution_id }, sid) =>
1068
1493
  del(`/deploy/solutions/${solution_id}`, sid),
1494
+
1495
+ ateam_delete_connector: async ({ solution_id, connector_id }, sid) =>
1496
+ del(`/deploy/solutions/${solution_id}/connectors/${connector_id}`, sid),
1497
+
1498
+ ateam_redeploy: async ({ solution_id }, sid) => {
1499
+ const result = await post(`/deploy/solutions/${solution_id}/redeploy`, {}, sid, { timeoutMs: 300_000 });
1500
+ return {
1501
+ ok: result.ok,
1502
+ solution_id,
1503
+ deployed: result.deployed || 0,
1504
+ failed: result.failed || 0,
1505
+ total: result.total || 0,
1506
+ skills: result.skills || [],
1507
+ message: result.ok
1508
+ ? `Re-deployed ${result.deployed || 0} skill(s) successfully.`
1509
+ : `Re-deploy had ${result.failed || 0} failure(s). Check skills array for details.`,
1510
+ };
1511
+ },
1512
+
1513
+ // ─── Master Key Bulk Tools ───────────────────────────────────────────
1514
+
1515
+ ateam_status_all: async (_args, sid) => {
1516
+ if (!isMasterMode(sid)) {
1517
+ return { ok: false, message: "Master key required. Call ateam_auth(master_key: \"<key>\", tenant: \"<any>\") first." };
1518
+ }
1519
+ const tenants = await listTenants(sid);
1520
+ const results = [];
1521
+ for (const t of tenants) {
1522
+ switchTenant(sid, t.id);
1523
+ try {
1524
+ const { solutions } = await get("/deploy/solutions", sid);
1525
+ for (const sol of (solutions || [])) {
1526
+ let ghStatus = null;
1527
+ try {
1528
+ ghStatus = await get(`/deploy/solutions/${sol.id}/github/status`, sid);
1529
+ } catch { /* no github config */ }
1530
+ results.push({
1531
+ tenant: t.id,
1532
+ solution: sol.id,
1533
+ name: sol.name || sol.id,
1534
+ github: ghStatus ? {
1535
+ repo: ghStatus.repo || ghStatus.repoUrl,
1536
+ lastCommit: ghStatus.lastCommit?.message?.slice(0, 60),
1537
+ lastPush: ghStatus.lastCommit?.date,
1538
+ branch: ghStatus.branch,
1539
+ } : "not configured",
1540
+ });
1541
+ }
1542
+ } catch (err) {
1543
+ results.push({ tenant: t.id, error: err.message });
1544
+ }
1545
+ }
1546
+ return { ok: true, tenants: tenants.length, solutions: results.length, results };
1547
+ },
1548
+
1549
+ ateam_sync_all: async ({ push_only, pull_only }, sid) => {
1550
+ if (!isMasterMode(sid)) {
1551
+ return { ok: false, message: "Master key required. Call ateam_auth(master_key: \"<key>\", tenant: \"<any>\") first." };
1552
+ }
1553
+ const tenants = await listTenants(sid);
1554
+ const results = [];
1555
+ for (const t of tenants) {
1556
+ switchTenant(sid, t.id);
1557
+ try {
1558
+ const { solutions } = await get("/deploy/solutions", sid);
1559
+ for (const sol of (solutions || [])) {
1560
+ const entry = { tenant: t.id, solution: sol.id, name: sol.name || sol.id };
1561
+ // Push: Builder FS → GitHub
1562
+ if (!pull_only) {
1563
+ try {
1564
+ const pushResult = await post(`/deploy/solutions/${sol.id}/github/push`, {}, sid);
1565
+ entry.push = { ok: true, commit: pushResult.commitSha?.slice(0, 8), files: pushResult.filesCommitted };
1566
+ } catch (err) {
1567
+ entry.push = { ok: false, error: err.message.slice(0, 100) };
1568
+ }
1569
+ }
1570
+ // Pull: GitHub → Core MongoDB
1571
+ if (!push_only) {
1572
+ try {
1573
+ const pullResult = await post(`/deploy/solutions/${sol.id}/github/pull`, {}, sid);
1574
+ entry.pull = { ok: true, skills: pullResult.skills?.length, connectors: pullResult.connectors?.length };
1575
+ } catch (err) {
1576
+ entry.pull = { ok: false, error: err.message.slice(0, 100) };
1577
+ }
1578
+ }
1579
+ results.push(entry);
1580
+ }
1581
+ } catch (err) {
1582
+ results.push({ tenant: t.id, error: err.message });
1583
+ }
1584
+ }
1585
+ const pushCount = results.filter(r => r.push?.ok).length;
1586
+ const pullCount = results.filter(r => r.pull?.ok).length;
1587
+ const errors = results.filter(r => r.error || r.push?.ok === false || r.pull?.ok === false).length;
1588
+ return {
1589
+ ok: errors === 0,
1590
+ summary: `Synced ${tenants.length} tenant(s), ${results.length} solution(s). Push: ${pushCount} ok. Pull: ${pullCount} ok. Errors: ${errors}.`,
1591
+ results,
1592
+ };
1593
+ },
1069
1594
  };
1070
1595
 
1071
1596
  // ─── Response formatting ────────────────────────────────────────────
@@ -1172,6 +1697,11 @@ export async function handleToolCall(name, args, sessionId) {
1172
1697
  };
1173
1698
  }
1174
1699
 
1700
+ // Master mode: per-call tenant override (no re-auth needed)
1701
+ if (TENANT_TOOLS.has(name) && isMasterMode(sessionId) && args?.tenant) {
1702
+ switchTenant(sessionId, args.tenant);
1703
+ }
1704
+
1175
1705
  try {
1176
1706
  const result = await handler(args, sessionId);
1177
1707