@ateam-ai/mcp 0.2.1 → 0.2.3

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.1",
3
+ "version": "0.2.3",
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
@@ -5,23 +5,38 @@
5
5
  * 1. Per-session override (set via ateam_auth tool — used by HTTP transport)
6
6
  * 2. Environment variables (ADAS_API_KEY, ADAS_TENANT — used by stdio transport)
7
7
  * 3. Defaults (no key, tenant "main")
8
+ *
9
+ * Sessions also track activity timestamps and optional context (active solution,
10
+ * last skill) to support TTL-based cleanup and smarter UX.
8
11
  */
9
12
 
10
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.)
11
15
  const ENV_TENANT = process.env.ADAS_TENANT || "";
12
16
  const ENV_API_KEY = process.env.ADAS_API_KEY || "";
13
17
 
14
- // Request timeout (30 seconds)
15
- const REQUEST_TIMEOUT_MS = 30_000;
18
+ // Request timeout (120 seconds — deploys can take 60-90s)
19
+ const REQUEST_TIMEOUT_MS = 120_000;
20
+
21
+ // Session TTL — sessions idle longer than this are swept
22
+ const SESSION_TTL = 60 * 60 * 1000; // 60 minutes
23
+
24
+ // Sweep interval — how often we check for stale sessions
25
+ const SWEEP_INTERVAL = 5 * 60 * 1000; // every 5 minutes
16
26
 
17
- // Per-session credential store (sessionId → { tenant, apiKey })
27
+ // Per-session store (sessionId → { tenant, apiKey, lastActivity, context })
28
+ // context: { activeSolutionId, lastSkillId, lastToolName }
18
29
  const sessions = new Map();
19
30
 
20
- // Per-tenant credential fallback for MCP clients that don't persist sessions
21
- // (e.g., ChatGPT's bridge creates a new session per tool call).
22
- // Keyed by tenant to prevent cross-user credential leaks in shared MCP servers.
23
- const tenantFallbacks = new Map(); // tenant { tenant, apiKey, createdAt }
24
- const FALLBACK_TTL = 60 * 60 * 1000; // 60 minutes
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
25
40
 
26
41
  /**
27
42
  * Parse a tenant-embedded API key.
@@ -39,22 +54,51 @@ export function parseApiKey(key) {
39
54
  }
40
55
 
41
56
  /**
42
- * Set credentials for a session (called by ateam_auth tool).
57
+ * Set credentials for a session.
43
58
  * If tenant is not provided, it's auto-extracted from the key.
44
- * Also updates the global fallback so new sessions inherit credentials.
59
+ * Set explicit=true when called from ateam_auth (not from seedCredentials).
60
+ * Set masterKey for cross-tenant master mode (uses shared secret auth).
45
61
  */
46
- export function setSessionCredentials(sessionId, { tenant, apiKey }) {
62
+ export function setSessionCredentials(sessionId, { tenant, apiKey, apiUrl, explicit = false, masterKey = null }) {
47
63
  let resolvedTenant = tenant;
48
64
  if (!resolvedTenant && apiKey) {
49
65
  const parsed = parseApiKey(apiKey);
50
66
  if (parsed.tenant) resolvedTenant = parsed.tenant;
51
67
  }
52
- const creds = { tenant: resolvedTenant || "main", apiKey };
53
- sessions.set(sessionId, creds);
68
+ const existing = sessions.get(sessionId);
69
+ sessions.set(sessionId, {
70
+ tenant: resolvedTenant || "main",
71
+ apiKey,
72
+ apiUrl: apiUrl || existing?.apiUrl || null,
73
+ authExplicit: explicit || existing?.authExplicit || false,
74
+ masterKey: masterKey || existing?.masterKey || null,
75
+ lastActivity: Date.now(),
76
+ context: existing?.context || {},
77
+ });
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
+ }
54
82
 
55
- // Update per-tenant fallback — only sessions for the SAME tenant will inherit this
56
- tenantFallbacks.set(creds.tenant, { ...creds, createdAt: Date.now() });
57
- console.log(`[Auth] Credentials set for session ${sessionId}, tenant fallback updated (tenant: ${creds.tenant})`);
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);
58
102
  }
59
103
 
60
104
  /**
@@ -62,10 +106,6 @@ export function setSessionCredentials(sessionId, { tenant, apiKey }) {
62
106
  * Resolution order:
63
107
  * 1. Per-session (from ateam_auth or seedCredentials)
64
108
  * 2. Environment variables (ADAS_API_KEY, ADAS_TENANT)
65
- *
66
- * Note: tenantFallbacks are NOT used in getCredentials() to prevent
67
- * cross-user credential leaks. They are only used in seedFromFallback()
68
- * which requires explicit tenant matching.
69
109
  */
70
110
  export function getCredentials(sessionId) {
71
111
  // 1. Per-session credentials
@@ -84,21 +124,6 @@ export function getCredentials(sessionId) {
84
124
  return { tenant: tenant || "main", apiKey };
85
125
  }
86
126
 
87
- /**
88
- * Seed a session's credentials from a matching tenant fallback.
89
- * Called by HTTP transport when a new session is created with a known tenant
90
- * (e.g., from OAuth token). Only inherits from the SAME tenant.
91
- */
92
- export function seedFromFallback(sessionId, tenant) {
93
- const fallback = tenantFallbacks.get(tenant);
94
- if (fallback && (Date.now() - fallback.createdAt < FALLBACK_TTL)) {
95
- sessions.set(sessionId, { tenant: fallback.tenant, apiKey: fallback.apiKey });
96
- console.log(`[Auth] Seeded session ${sessionId} from tenant fallback (tenant: ${tenant})`);
97
- return true;
98
- }
99
- return false;
100
- }
101
-
102
127
  /**
103
128
  * Check if a session is authenticated (has an API key from any source).
104
129
  */
@@ -109,23 +134,181 @@ export function isAuthenticated(sessionId) {
109
134
 
110
135
  /**
111
136
  * Check if a session has been explicitly authenticated via ateam_auth.
112
- * This checks ONLY per-session credentials, ignoring env vars.
113
- * Used to gate mutating operations — env vars alone are not sufficient
114
- * to deploy, update, or delete solutions.
137
+ * Checks per-session credentials AND bearer auth overrides.
138
+ * Used to gate tenant-aware operations — env vars alone are not sufficient
139
+ * to deploy, update, or read solutions.
115
140
  */
116
141
  export function isExplicitlyAuthenticated(sessionId) {
117
142
  if (!sessionId) return false;
118
- 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);
148
+ }
149
+
150
+ /**
151
+ * Record activity on a session — called on every tool call.
152
+ * Keeps the session alive and updates context for smarter UX.
153
+ */
154
+ export function touchSession(sessionId, { toolName, solutionId, skillId } = {}) {
155
+ const session = sessions.get(sessionId);
156
+ if (!session) return;
157
+
158
+ session.lastActivity = Date.now();
159
+
160
+ // Update context — track what the user is working on
161
+ if (toolName) session.context.lastToolName = toolName;
162
+ if (solutionId) session.context.activeSolutionId = solutionId;
163
+ if (skillId) session.context.lastSkillId = skillId;
164
+ }
165
+
166
+ /**
167
+ * Get session context — what the user has been working on.
168
+ * Returns {} if no session or no context.
169
+ */
170
+ export function getSessionContext(sessionId) {
171
+ const session = sessions.get(sessionId);
172
+ if (!session) return {};
173
+ return { ...session.context };
119
174
  }
120
175
 
121
176
  /**
122
177
  * Remove session credentials (on disconnect).
123
178
  */
124
179
  export function clearSession(sessionId) {
180
+ sessionBearers.delete(sessionId);
125
181
  sessions.delete(sessionId);
126
182
  }
127
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
+
243
+ /**
244
+ * Sweep expired sessions — removes sessions idle longer than SESSION_TTL.
245
+ * Returns the number of sessions removed.
246
+ */
247
+ export function sweepStaleSessions() {
248
+ const now = Date.now();
249
+ let swept = 0;
250
+ for (const [sid, session] of sessions) {
251
+ if (now - session.lastActivity > SESSION_TTL) {
252
+ sessionBearers.delete(sid);
253
+ sessions.delete(sid);
254
+ swept++;
255
+ }
256
+ }
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.`);
267
+ }
268
+ return swept;
269
+ }
270
+
271
+ /**
272
+ * Start the periodic session sweep timer.
273
+ * Called once from HTTP transport on startup.
274
+ */
275
+ export function startSessionSweeper() {
276
+ const timer = setInterval(sweepStaleSessions, SWEEP_INTERVAL);
277
+ timer.unref(); // don't prevent process exit
278
+ console.log(`[Session] Sweep timer started (interval: ${SWEEP_INTERVAL / 1000}s, TTL: ${SESSION_TTL / 1000}s)`);
279
+ return timer;
280
+ }
281
+
282
+ /**
283
+ * Get session stats — for health checks and debugging.
284
+ */
285
+ export function getSessionStats() {
286
+ const now = Date.now();
287
+ let oldest = Infinity;
288
+ let newest = 0;
289
+ for (const [, session] of sessions) {
290
+ if (session.lastActivity < oldest) oldest = session.lastActivity;
291
+ if (session.lastActivity > newest) newest = session.lastActivity;
292
+ }
293
+ return {
294
+ active: sessions.size,
295
+ oldestAge: sessions.size > 0 ? Math.round((now - oldest) / 1000) : 0,
296
+ newestAge: sessions.size > 0 ? Math.round((now - newest) / 1000) : 0,
297
+ };
298
+ }
299
+
128
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
129
312
  const { tenant, apiKey } = getCredentials(sessionId);
130
313
  const h = { "Content-Type": "application/json" };
131
314
  if (tenant) h["X-ADAS-TENANT"] = tenant;
@@ -172,6 +355,7 @@ async function request(method, path, body, sessionId, opts = {}) {
172
355
  const timeoutMs = opts.timeoutMs || REQUEST_TIMEOUT_MS;
173
356
  const controller = new AbortController();
174
357
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
358
+ const baseUrl = getBaseUrl(sessionId);
175
359
 
176
360
  try {
177
361
  const fetchOpts = {
@@ -183,7 +367,7 @@ async function request(method, path, body, sessionId, opts = {}) {
183
367
  fetchOpts.body = JSON.stringify(body);
184
368
  }
185
369
 
186
- const res = await fetch(`${BASE_URL}${path}`, fetchOpts);
370
+ const res = await fetch(`${baseUrl}${path}`, fetchOpts);
187
371
 
188
372
  if (!res.ok) {
189
373
  const text = await res.text().catch(() => "");
@@ -195,18 +379,18 @@ async function request(method, path, body, sessionId, opts = {}) {
195
379
  if (err.name === "AbortError") {
196
380
  throw new Error(
197
381
  `A-Team API timeout: ${method} ${path} did not respond within ${timeoutMs / 1000}s.\n` +
198
- `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`
199
383
  );
200
384
  }
201
385
  if (err.cause?.code === "ECONNREFUSED") {
202
386
  throw new Error(
203
- `Cannot connect to A-Team API at ${BASE_URL}.\n` +
204
- `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`
205
389
  );
206
390
  }
207
391
  if (err.cause?.code === "ENOTFOUND") {
208
392
  throw new Error(
209
- `Cannot resolve A-Team API host: ${BASE_URL}.\n` +
393
+ `Cannot resolve A-Team API host: ${baseUrl}.\n` +
210
394
  `Hint: Check your internet connection and ADAS_API_URL setting.`
211
395
  );
212
396
  }
@@ -231,3 +415,36 @@ export async function patch(path, body, sessionId, opts) {
231
415
  export async function del(path, sessionId, opts) {
232
416
  return request("DELETE", path, undefined, sessionId, opts);
233
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
@@ -23,7 +23,11 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
23
23
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
24
24
  import express from "express";
25
25
  import { createServer } from "./server.js";
26
- import { clearSession, setSessionCredentials, parseApiKey } from "./api.js";
26
+ import {
27
+ clearSession, setSessionCredentials, parseApiKey,
28
+ startSessionSweeper, getSessionStats, sweepStaleSessions,
29
+ bindSessionBearer, getAuthOverride,
30
+ } from "./api.js";
27
31
  import { mountOAuth } from "./oauth.js";
28
32
 
29
33
  // Active sessions
@@ -169,7 +173,12 @@ export function startHttpServer(port = 3100) {
169
173
 
170
174
  // ─── Health check ─────────────────────────────────────────────
171
175
  app.get("/health", (_req, res) => {
172
- res.json({ ok: true, service: "ateam-mcp", transport: "http" });
176
+ res.json({
177
+ ok: true,
178
+ service: "ateam-mcp",
179
+ transport: "http",
180
+ sessions: getSessionStats(),
181
+ });
173
182
  });
174
183
 
175
184
  // ─── Get API Key — redirect to Skill Builder with auto-open ──
@@ -189,8 +198,13 @@ export function startHttpServer(port = 3100) {
189
198
  // Reuse existing session — seed credentials if Bearer token present
190
199
  transport = transports[sessionId];
191
200
  seedCredentials(req, sessionId);
192
- } else if (!sessionId && isInitializeRequest(req.body)) {
193
- // 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
+
194
208
  const newSessionId = randomUUID();
195
209
 
196
210
  // Seed credentials from OAuth Bearer token before server starts
@@ -216,6 +230,16 @@ export function startHttpServer(port = 3100) {
216
230
  await server.connect(transport);
217
231
  await transport.handleRequest(req, res, req.body);
218
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;
219
243
  } else {
220
244
  res.status(400).json({
221
245
  jsonrpc: "2.0",
@@ -290,8 +314,12 @@ export function startHttpServer(port = 3100) {
290
314
  console.log(` Health check: http://localhost:${port}/health`);
291
315
  });
292
316
 
293
- // Graceful shutdown
317
+ // Start periodic session cleanup (sweeps stale sessions every 5 min)
318
+ startSessionSweeper();
319
+
320
+ // Graceful shutdown — close all transports and clear sessions
294
321
  process.on("SIGINT", async () => {
322
+ console.log(`[HTTP] Shutting down — closing ${Object.keys(transports).length} transport(s)...`);
295
323
  for (const sid of Object.keys(transports)) {
296
324
  try {
297
325
  await transports[sid].close();
@@ -314,13 +342,27 @@ function getNewestToken() {
314
342
  }
315
343
 
316
344
  /**
317
- * If the request has a validated Bearer token (set by requireBearerAuth),
318
- * 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.
319
350
  */
320
351
  function seedCredentials(req, sessionId) {
321
352
  const token = req.auth?.token;
322
353
  if (!token) return;
323
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
324
366
  const parsed = parseApiKey(token);
325
367
  if (parsed.isValid) {
326
368
  setSessionCredentials(sessionId, { tenant: parsed.tenant, apiKey: token });
package/src/tools.js CHANGED
@@ -11,7 +11,8 @@
11
11
  import {
12
12
  get, post, patch, del,
13
13
  setSessionCredentials, isAuthenticated, isExplicitlyAuthenticated,
14
- getCredentials, parseApiKey,
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,165 @@ 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_status_all",
821
+ core: true,
822
+ description:
823
+ "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.",
824
+ inputSchema: {
825
+ type: "object",
826
+ properties: {},
827
+ },
828
+ },
829
+ {
830
+ name: "ateam_sync_all",
831
+ core: true,
832
+ description:
833
+ "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.",
834
+ inputSchema: {
835
+ type: "object",
836
+ properties: {
837
+ push_only: {
838
+ type: "boolean",
839
+ description: "Only push to GitHub (skip pull to Core). Default: false (full sync).",
840
+ },
841
+ pull_only: {
842
+ type: "boolean",
843
+ description: "Only pull from GitHub to Core (skip push). Default: false (full sync).",
844
+ },
845
+ },
846
+ },
847
+ },
600
848
  ];
601
849
 
602
850
  /**
@@ -612,6 +860,7 @@ const SPEC_PATHS = {
612
860
  skill: "/spec/skill",
613
861
  solution: "/spec/solution",
614
862
  enums: "/spec/enums",
863
+ "connector-multi-user": "/spec/multi-user-connector",
615
864
  };
616
865
 
617
866
  const EXAMPLE_PATHS = {
@@ -637,17 +886,30 @@ const TENANT_TOOLS = new Set([
637
886
  "ateam_update",
638
887
  "ateam_redeploy",
639
888
  "ateam_delete_solution",
889
+ "ateam_delete_connector",
640
890
  "ateam_solution_chat",
641
891
  // Read operations (tenant-specific data)
642
892
  "ateam_list_solutions",
643
893
  "ateam_get_solution",
644
894
  "ateam_get_execution_logs",
645
895
  "ateam_test_skill",
896
+ "ateam_test_pipeline",
897
+ "ateam_test_voice",
646
898
  "ateam_test_status",
647
899
  "ateam_test_abort",
648
900
  "ateam_get_connector_source",
649
901
  "ateam_get_metrics",
650
902
  "ateam_diff",
903
+ // GitHub operations
904
+ "ateam_github_push",
905
+ "ateam_github_pull",
906
+ "ateam_github_status",
907
+ "ateam_github_read",
908
+ "ateam_github_patch",
909
+ "ateam_github_log",
910
+ // Master key bulk operations
911
+ "ateam_status_all",
912
+ "ateam_sync_all",
651
913
  ]);
652
914
 
653
915
  /** Small delay helper */
@@ -676,12 +938,13 @@ const handlers = {
676
938
  { name: "Enterprise Compliance Platform", description: "Approval flows, audit logs, policy enforcement" },
677
939
  ],
678
940
  developer_loop: {
679
- _note: "This is the recommended build loop. Only 4 steps from definition to running skill.",
941
+ _note: "This is the recommended build loop. 5 steps from definition to running skill with GitHub version control.",
680
942
  steps: [
681
943
  { 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"] },
944
+ { 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"] },
945
+ { 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"] },
946
+ { 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"] },
947
+ { 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
948
  ],
686
949
  },
687
950
  first_questions: [
@@ -690,6 +953,27 @@ const handlers = {
690
953
  { id: "systems", question: "Which systems should the Team connect to?", type: "multi_select", options: ["slack", "email", "zendesk", "shopify", "jira", "postgres", "custom_api", "none"] },
691
954
  { id: "security", question: "What environment constraints?", type: "enum", options: ["sandbox", "controlled", "regulated"] },
692
955
  ],
956
+ github_tools: {
957
+ _note: "Version control for solutions. Every deploy auto-pushes to GitHub. The repo is the source of truth for connector code.",
958
+ tools: ["ateam_github_push", "ateam_github_pull", "ateam_github_status", "ateam_github_read", "ateam_github_patch", "ateam_github_log"],
959
+ repo_structure: {
960
+ "solution.json": "Full solution definition",
961
+ "skills/{skill-id}/skill.json": "Individual skill definitions",
962
+ "connectors/{connector-id}/server.js": "Connector MCP server code",
963
+ "connectors/{connector-id}/package.json": "Connector dependencies",
964
+ },
965
+ iteration_workflow: {
966
+ code_changes: "ateam_github_patch (edit connector files) → ateam_build_and_run(github:true) (redeploy from repo)",
967
+ definition_changes: "ateam_patch (edit skill/solution definitions directly in Builder)",
968
+ first_deploy: "Must include mcp_store — this creates the GitHub repo",
969
+ },
970
+ when_to_use_what: {
971
+ ateam_github_patch: "Edit connector source code (server.js, utils, package.json, UI assets)",
972
+ ateam_patch: "Edit skill definitions (intents, tools, policy) or solution definitions (grants, handoffs, routing)",
973
+ "ateam_build_and_run(github:true)": "Redeploy solution pulling latest connector code from GitHub",
974
+ "ateam_build_and_run(mcp_store)": "First deploy or when you want to pass connector code inline",
975
+ },
976
+ },
693
977
  advanced_tools: {
694
978
  _note: "These tools are available but hidden from the default tool list. Call them by name when you need fine-grained control.",
695
979
  debugging: ["ateam_get_execution_logs", "ateam_get_metrics", "ateam_diff", "ateam_get_connector_source"],
@@ -728,7 +1012,8 @@ const handlers = {
728
1012
  always: [
729
1013
  "Explain Skill vs Solution vs Connector before building",
730
1014
  "Use ateam_build_and_run for the full lifecycle (validates automatically)",
731
- "Use ateam_patch for iterations (updates + redeploys automatically)",
1015
+ "Use ateam_patch for skill/solution definition changes (updates + redeploys automatically)",
1016
+ "Use ateam_github_patch + ateam_build_and_run(github:true) for connector code changes after first deploy",
732
1017
  "Study the connector example (ateam_get_examples type='connector') before writing connector code",
733
1018
  "Ask discovery questions if goal unclear",
734
1019
  ],
@@ -741,21 +1026,52 @@ const handlers = {
741
1026
  },
742
1027
  }),
743
1028
 
744
- ateam_auth: async ({ api_key, tenant }, sessionId) => {
1029
+ ateam_auth: async ({ api_key, master_key, tenant, url }, sessionId) => {
1030
+ // Master key mode: cross-tenant auth using shared secret
1031
+ if (master_key) {
1032
+ if (!tenant) {
1033
+ return { ok: false, message: "Master key requires a tenant parameter. Specify which tenant to operate on." };
1034
+ }
1035
+ const apiUrl = url ? url.replace(/\/+$/, "") : undefined;
1036
+ setSessionCredentials(sessionId, { tenant, apiKey: null, apiUrl, explicit: true, masterKey: master_key });
1037
+ // Verify by listing solutions
1038
+ try {
1039
+ const result = await get("/deploy/solutions", sessionId);
1040
+ const urlNote = apiUrl ? ` (via ${apiUrl})` : "";
1041
+ return {
1042
+ ok: true,
1043
+ tenant,
1044
+ masterMode: true,
1045
+ 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.`,
1046
+ };
1047
+ } catch (err) {
1048
+ return { ok: false, tenant, message: `Master key auth failed: ${err.message}` };
1049
+ }
1050
+ }
1051
+
1052
+ // Normal API key mode
1053
+ if (!api_key) {
1054
+ return { ok: false, message: "Provide either api_key or master_key." };
1055
+ }
745
1056
  // Auto-extract tenant from key if not provided
746
1057
  let resolvedTenant = tenant;
747
1058
  if (!resolvedTenant) {
748
1059
  const parsed = parseApiKey(api_key);
749
1060
  resolvedTenant = parsed.tenant || "main";
750
1061
  }
751
- setSessionCredentials(sessionId, { tenant: resolvedTenant, apiKey: api_key });
1062
+ // Normalize URL: strip trailing slash
1063
+ const apiUrl = url ? url.replace(/\/+$/, "") : undefined;
1064
+ setSessionCredentials(sessionId, { tenant: resolvedTenant, apiKey: api_key, apiUrl, explicit: true });
1065
+ // Persist override per bearer (survives session changes)
1066
+ setAuthOverride(sessionId, { tenant: resolvedTenant, apiKey: api_key, apiUrl });
752
1067
  // Verify the key works by listing solutions
753
1068
  try {
754
1069
  const result = await get("/deploy/solutions", sessionId);
1070
+ const urlNote = apiUrl ? ` (via ${apiUrl})` : "";
755
1071
  return {
756
1072
  ok: true,
757
1073
  tenant: resolvedTenant,
758
- message: `Authenticated to tenant "${resolvedTenant}". ${result.solutions?.length || 0} solution(s) found.`,
1074
+ message: `Authenticated to tenant "${resolvedTenant}"${urlNote}. ${result.solutions?.length || 0} solution(s) found.`,
759
1075
  };
760
1076
  } catch (err) {
761
1077
  return {
@@ -776,13 +1092,49 @@ const handlers = {
776
1092
  // Validates → Deploys → Health-checks → Optionally tests
777
1093
  // One call replaces: validate_solution + deploy_solution + get_solution(health)
778
1094
 
779
- ateam_build_and_run: async ({ solution, skills, connectors, mcp_store, test_message, test_skill_id }, sid) => {
1095
+ ateam_build_and_run: async ({ solution, skills, connectors, mcp_store, github, test_message, test_skill_id }, sid) => {
780
1096
  const phases = [];
781
1097
 
1098
+ // Phase 0: GitHub pull (if github:true — pull connector source from repo)
1099
+ let effectiveMcpStore = mcp_store;
1100
+ if (github && !mcp_store) {
1101
+ try {
1102
+ const pullResult = await post(
1103
+ `/deploy/solutions/${solution.id}/github/pull-connectors`,
1104
+ {},
1105
+ sid,
1106
+ { timeoutMs: 30_000 },
1107
+ );
1108
+ if (!pullResult.ok) {
1109
+ return {
1110
+ ok: false,
1111
+ phase: "github_pull",
1112
+ error: pullResult.error || "Failed to pull connectors from GitHub",
1113
+ hint: pullResult.hint || "Deploy the solution first (with mcp_store) to auto-create the GitHub repo.",
1114
+ message: "Cannot pull connector code from GitHub. The repo may not exist yet — deploy with mcp_store first.",
1115
+ };
1116
+ }
1117
+ effectiveMcpStore = pullResult.mcp_store;
1118
+ phases.push({
1119
+ phase: "github_pull",
1120
+ status: "done",
1121
+ connectors_found: pullResult.connectors_found || 0,
1122
+ files_loaded: pullResult.files_loaded || 0,
1123
+ });
1124
+ } catch (err) {
1125
+ return {
1126
+ ok: false,
1127
+ phase: "github_pull",
1128
+ error: err.message,
1129
+ message: "Failed to pull connector code from GitHub. The repo may not exist yet — deploy with mcp_store first.",
1130
+ };
1131
+ }
1132
+ }
1133
+
782
1134
  // Phase 1: Validate
783
1135
  let validation;
784
1136
  try {
785
- validation = await post("/validate/solution", { solution, skills }, sid);
1137
+ validation = await post("/validate/solution", { solution, skills, connectors, mcp_store: effectiveMcpStore }, sid, { timeoutMs: 120_000 });
786
1138
  phases.push({ phase: "validate", status: "done" });
787
1139
  } catch (err) {
788
1140
  return {
@@ -808,7 +1160,7 @@ const handlers = {
808
1160
  // Phase 2: Deploy
809
1161
  let deploy;
810
1162
  try {
811
- deploy = await post("/deploy/solution", { solution, skills, connectors, mcp_store }, sid);
1163
+ deploy = await post("/deploy/solution", { solution, skills, connectors, mcp_store: effectiveMcpStore }, sid, { timeoutMs: 300_000 });
812
1164
  phases.push({ phase: "deploy", status: deploy.ok ? "done" : "failed" });
813
1165
  } catch (err) {
814
1166
  return {
@@ -863,6 +1215,25 @@ const handlers = {
863
1215
  }
864
1216
  }
865
1217
 
1218
+ // Phase 5: GitHub push (auto — non-blocking, failures don't fail the deploy)
1219
+ let github_result;
1220
+ try {
1221
+ github_result = await post(
1222
+ `/deploy/solutions/${solution.id}/github/push`,
1223
+ { message: `Deploy: ${solution.name || solution.id}` },
1224
+ sid,
1225
+ { timeoutMs: 60_000 },
1226
+ );
1227
+ phases.push({
1228
+ phase: "github",
1229
+ status: github_result.skipped ? "skipped" : "done",
1230
+ ...(github_result.repo_url && { repo_url: github_result.repo_url }),
1231
+ });
1232
+ } catch (err) {
1233
+ github_result = { error: err.message };
1234
+ phases.push({ phase: "github", status: "error", error: err.message });
1235
+ }
1236
+
866
1237
  return {
867
1238
  ok: true,
868
1239
  solution_id: solution.id,
@@ -875,6 +1246,7 @@ const handlers = {
875
1246
  },
876
1247
  health,
877
1248
  ...(test_result && { test_result }),
1249
+ ...(github_result && !github_result.error && !github_result.skipped && { github: github_result }),
878
1250
  ...(validation.warnings?.length > 0 && { validation_warnings: validation.warnings }),
879
1251
  };
880
1252
  },
@@ -956,8 +1328,8 @@ const handlers = {
956
1328
 
957
1329
  ateam_validate_skill: async ({ skill }, sid) => post("/validate/skill", { skill }, sid),
958
1330
 
959
- ateam_validate_solution: async ({ solution, skills }, sid) =>
960
- post("/validate/solution", { solution, skills }, sid),
1331
+ ateam_validate_solution: async ({ solution, skills, connectors, mcp_store }, sid) =>
1332
+ post("/validate/solution", { solution, skills, connectors, mcp_store }, sid),
961
1333
 
962
1334
  ateam_deploy_solution: async ({ solution, skills, connectors, mcp_store }, sid) =>
963
1335
  post("/deploy/solution", { solution, skills, connectors, mcp_store }, sid),
@@ -1042,6 +1414,20 @@ const handlers = {
1042
1414
  return post(`/deploy/solutions/${solution_id}/skills/${skill_id}/test`, body, sid, { timeoutMs });
1043
1415
  },
1044
1416
 
1417
+ ateam_test_pipeline: async ({ solution_id, skill_id, message }, sid) =>
1418
+ post(`/deploy/solutions/${solution_id}/skills/${skill_id}/test-pipeline`, { message }, sid, { timeoutMs: 30_000 }),
1419
+
1420
+ ateam_test_voice: async ({ solution_id, messages, phone_number, skill_slug, timeout_ms }, sid) => {
1421
+ const body = { messages };
1422
+ if (phone_number) body.phone_number = phone_number;
1423
+ if (skill_slug) body.skill_slug = skill_slug;
1424
+ if (timeout_ms) body.timeout_ms = timeout_ms;
1425
+ // Timeout scales with message count — each turn may invoke skills
1426
+ const perTurnMs = timeout_ms || 60_000;
1427
+ const timeoutTotal = Math.min(perTurnMs * messages.length + 30_000, 600_000);
1428
+ return post(`/deploy/voice-test`, body, sid, { timeoutMs: timeoutTotal });
1429
+ },
1430
+
1045
1431
  ateam_test_status: async ({ solution_id, skill_id, job_id }, sid) =>
1046
1432
  get(`/deploy/solutions/${solution_id}/skills/${skill_id}/test/${job_id}`, sid),
1047
1433
 
@@ -1064,8 +1450,115 @@ const handlers = {
1064
1450
  return get(`/deploy/solutions/${solution_id}/diff${qs}`, sid);
1065
1451
  },
1066
1452
 
1453
+ // ─── GitHub tools ──────────────────────────────────────────────────
1454
+
1455
+ ateam_github_push: async ({ solution_id, message }, sid) =>
1456
+ post(`/deploy/solutions/${solution_id}/github/push`, { message }, sid, { timeoutMs: 60_000 }),
1457
+
1458
+ ateam_github_pull: async ({ solution_id }, sid) =>
1459
+ post(`/deploy/solutions/${solution_id}/github/pull`, {}, sid, { timeoutMs: 300_000 }),
1460
+
1461
+ ateam_github_status: async ({ solution_id }, sid) =>
1462
+ get(`/deploy/solutions/${solution_id}/github/status`, sid),
1463
+
1464
+ ateam_github_read: async ({ solution_id, path: filePath }, sid) =>
1465
+ get(`/deploy/solutions/${solution_id}/github/read?path=${encodeURIComponent(filePath)}`, sid),
1466
+
1467
+ ateam_github_patch: async ({ solution_id, path: filePath, content, message }, sid) =>
1468
+ post(`/deploy/solutions/${solution_id}/github/patch`, { path: filePath, content, message }, sid),
1469
+
1470
+ ateam_github_log: async ({ solution_id, limit }, sid) => {
1471
+ const qs = limit ? `?limit=${limit}` : "";
1472
+ return get(`/deploy/solutions/${solution_id}/github/log${qs}`, sid);
1473
+ },
1474
+
1067
1475
  ateam_delete_solution: async ({ solution_id }, sid) =>
1068
1476
  del(`/deploy/solutions/${solution_id}`, sid),
1477
+
1478
+ ateam_delete_connector: async ({ solution_id, connector_id }, sid) =>
1479
+ del(`/deploy/solutions/${solution_id}/connectors/${connector_id}`, sid),
1480
+
1481
+ // ─── Master Key Bulk Tools ───────────────────────────────────────────
1482
+
1483
+ ateam_status_all: async (_args, sid) => {
1484
+ if (!isMasterMode(sid)) {
1485
+ return { ok: false, message: "Master key required. Call ateam_auth(master_key: \"<key>\", tenant: \"<any>\") first." };
1486
+ }
1487
+ const tenants = await listTenants(sid);
1488
+ const results = [];
1489
+ for (const t of tenants) {
1490
+ switchTenant(sid, t.id);
1491
+ try {
1492
+ const { solutions } = await get("/deploy/solutions", sid);
1493
+ for (const sol of (solutions || [])) {
1494
+ let ghStatus = null;
1495
+ try {
1496
+ ghStatus = await get(`/deploy/solutions/${sol.id}/github/status`, sid);
1497
+ } catch { /* no github config */ }
1498
+ results.push({
1499
+ tenant: t.id,
1500
+ solution: sol.id,
1501
+ name: sol.name || sol.id,
1502
+ github: ghStatus ? {
1503
+ repo: ghStatus.repo || ghStatus.repoUrl,
1504
+ lastCommit: ghStatus.lastCommit?.message?.slice(0, 60),
1505
+ lastPush: ghStatus.lastCommit?.date,
1506
+ branch: ghStatus.branch,
1507
+ } : "not configured",
1508
+ });
1509
+ }
1510
+ } catch (err) {
1511
+ results.push({ tenant: t.id, error: err.message });
1512
+ }
1513
+ }
1514
+ return { ok: true, tenants: tenants.length, solutions: results.length, results };
1515
+ },
1516
+
1517
+ ateam_sync_all: async ({ push_only, pull_only }, sid) => {
1518
+ if (!isMasterMode(sid)) {
1519
+ return { ok: false, message: "Master key required. Call ateam_auth(master_key: \"<key>\", tenant: \"<any>\") first." };
1520
+ }
1521
+ const tenants = await listTenants(sid);
1522
+ const results = [];
1523
+ for (const t of tenants) {
1524
+ switchTenant(sid, t.id);
1525
+ try {
1526
+ const { solutions } = await get("/deploy/solutions", sid);
1527
+ for (const sol of (solutions || [])) {
1528
+ const entry = { tenant: t.id, solution: sol.id, name: sol.name || sol.id };
1529
+ // Push: Builder FS → GitHub
1530
+ if (!pull_only) {
1531
+ try {
1532
+ const pushResult = await post(`/deploy/solutions/${sol.id}/github/push`, {}, sid);
1533
+ entry.push = { ok: true, commit: pushResult.commitSha?.slice(0, 8), files: pushResult.filesCommitted };
1534
+ } catch (err) {
1535
+ entry.push = { ok: false, error: err.message.slice(0, 100) };
1536
+ }
1537
+ }
1538
+ // Pull: GitHub → Core MongoDB
1539
+ if (!push_only) {
1540
+ try {
1541
+ const pullResult = await post(`/deploy/solutions/${sol.id}/github/pull`, {}, sid);
1542
+ entry.pull = { ok: true, skills: pullResult.skills?.length, connectors: pullResult.connectors?.length };
1543
+ } catch (err) {
1544
+ entry.pull = { ok: false, error: err.message.slice(0, 100) };
1545
+ }
1546
+ }
1547
+ results.push(entry);
1548
+ }
1549
+ } catch (err) {
1550
+ results.push({ tenant: t.id, error: err.message });
1551
+ }
1552
+ }
1553
+ const pushCount = results.filter(r => r.push?.ok).length;
1554
+ const pullCount = results.filter(r => r.pull?.ok).length;
1555
+ const errors = results.filter(r => r.error || r.push?.ok === false || r.pull?.ok === false).length;
1556
+ return {
1557
+ ok: errors === 0,
1558
+ summary: `Synced ${tenants.length} tenant(s), ${results.length} solution(s). Push: ${pushCount} ok. Pull: ${pullCount} ok. Errors: ${errors}.`,
1559
+ results,
1560
+ };
1561
+ },
1069
1562
  };
1070
1563
 
1071
1564
  // ─── Response formatting ────────────────────────────────────────────
@@ -1137,6 +1630,13 @@ export async function handleToolCall(name, args, sessionId) {
1137
1630
  };
1138
1631
  }
1139
1632
 
1633
+ // Track activity + context on every tool call (keeps session alive, records what user is working on)
1634
+ touchSession(sessionId, {
1635
+ toolName: name,
1636
+ solutionId: args?.solution_id,
1637
+ skillId: args?.skill_id,
1638
+ });
1639
+
1140
1640
  // Check auth for tenant-aware operations — requires explicit ateam_auth call.
1141
1641
  // Env vars (ADAS_API_KEY / ADAS_TENANT) are NOT sufficient — they may be
1142
1642
  // baked into MCP config and silently target the wrong tenant.
@@ -1165,8 +1665,27 @@ export async function handleToolCall(name, args, sessionId) {
1165
1665
  };
1166
1666
  }
1167
1667
 
1668
+ // Master mode: per-call tenant override (no re-auth needed)
1669
+ if (TENANT_TOOLS.has(name) && isMasterMode(sessionId) && args?.tenant) {
1670
+ switchTenant(sessionId, args.tenant);
1671
+ }
1672
+
1168
1673
  try {
1169
1674
  const result = await handler(args, sessionId);
1675
+
1676
+ // For ateam_bootstrap, inject session context so the LLM knows what the user was working on
1677
+ if (name === "ateam_bootstrap") {
1678
+ const ctx = getSessionContext(sessionId);
1679
+ if (ctx.activeSolutionId || ctx.lastSkillId) {
1680
+ result.session_context = {
1681
+ _note: "This user has an active session. You can reference their previous work.",
1682
+ active_solution_id: ctx.activeSolutionId || null,
1683
+ last_skill_id: ctx.lastSkillId || null,
1684
+ last_tool_used: ctx.lastToolName || null,
1685
+ };
1686
+ }
1687
+ }
1688
+
1170
1689
  return {
1171
1690
  content: [{ type: "text", text: formatResult(result, name) }],
1172
1691
  };