@ateam-ai/mcp 0.4.90 → 0.4.92
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 +2 -2
- package/src/api.js +159 -15
- package/src/http.js +34 -6
- package/src/tools.js +106 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ateam-ai/mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.92",
|
|
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",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"start:http": "node src/index.js --http",
|
|
14
14
|
"dev": "node --watch src/index.js",
|
|
15
15
|
"dev:http": "node --watch src/index.js --http",
|
|
16
|
-
"test": "node test/session-isolation.test.mjs && node test/widget-protocol.test.mjs && node test/actor-binding.test.mjs && node test/spec-topics.test.mjs && node test/example-types.test.mjs && node --test test/deploy-status-truth.test.mjs && node --test test/connector-source-provenance.test.mjs"
|
|
16
|
+
"test": "node test/session-isolation.test.mjs && node test/widget-protocol.test.mjs && node test/actor-binding.test.mjs && node test/spec-topics.test.mjs && node test/example-types.test.mjs && node --test test/deploy-status-truth.test.mjs && node --test test/connector-source-provenance.test.mjs && node --test test/bootstrap-base-url.test.mjs && node --test test/key-environment.test.mjs"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
19
19
|
"mcp",
|
package/src/api.js
CHANGED
|
@@ -39,18 +39,135 @@ const authOverrides = new Map(); // bearerToken → { tenant, apiKey, updatedAt
|
|
|
39
39
|
const sessionBearers = new Map(); // sessionId → bearerToken
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
42
|
+
* THE ENVIRONMENTS A KEY MAY NAME. A CLOSED SET, deliberately.
|
|
43
|
+
*
|
|
44
|
+
* If this were an open pattern like [a-z]+, a typo — `adas_prd_…` — would
|
|
45
|
+
* become a NEW VALID ENVIRONMENT rather than an error, and the caller would be
|
|
46
|
+
* routed somewhere that does not exist instead of being told they mistyped.
|
|
47
|
+
* That is the same silent-wrong class as an environment fallback. Add an
|
|
48
|
+
* environment HERE, in one place, or it does not exist.
|
|
49
|
+
*/
|
|
50
|
+
export const KEY_ENVIRONMENTS = Object.freeze({
|
|
51
|
+
prod: "https://api.ateam-ai.com",
|
|
52
|
+
dev: "https://dev-api.ateam-ai.com",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const TENANT_RE = "[a-z0-9][a-z0-9-]{0,28}[a-z0-9]";
|
|
56
|
+
const ENV_KEY_RE = new RegExp(`^adas_(${Object.keys(KEY_ENVIRONMENTS).join("|")})_(${TENANT_RE})_([0-9a-f]{32})$`);
|
|
57
|
+
const PLAIN_KEY_RE = new RegExp(`^adas_(${TENANT_RE})_([0-9a-f]{32})$`);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* THE SEALED FORM — `adas_<env>_<blob>`, where the tenant is INSIDE the blob.
|
|
61
|
+
*
|
|
62
|
+
* base64url of [version:1][nonce:8][AES-256-GCM(tenant)][tag:8][secret:16].
|
|
63
|
+
* The trailing 16 bytes — the actual secret — are in the clear; the sealing key
|
|
64
|
+
* protects ROUTING ONLY. So the blob is not "the encrypted key", and losing the
|
|
65
|
+
* sealing secret is an availability problem, not a credential breach.
|
|
66
|
+
*
|
|
67
|
+
* THIS FILE MUST NEVER DECODE IT, and the reason is specific to this package:
|
|
68
|
+
* `@ateam-ai/mcp` installs from npm onto developer laptops. Any decoder here
|
|
69
|
+
* would mean the sealing secret shipping with it. We learn the tenant by asking
|
|
70
|
+
* — GET /auth/whoami — never by parsing.
|
|
71
|
+
*
|
|
72
|
+
* Length bounds come from that byte layout: 1-char tenant = 34 bytes = 46
|
|
73
|
+
* base64url chars; 30-char tenant = 63 bytes = 84.
|
|
74
|
+
*/
|
|
75
|
+
const SEALED_KEY_RE = new RegExp(`^adas_(${Object.keys(KEY_ENVIRONMENTS).join("|")})_([A-Za-z0-9_-]{46,88})$`);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parse an API key.
|
|
79
|
+
* Sealed: adas_<env>_<blob> (tenant NOT in the string — ask whoami)
|
|
80
|
+
* Format: adas_<env>_<tenant>_<32hex> env ∈ prod|dev
|
|
81
|
+
* Older: adas_<tenant>_<32hex> (no environment named)
|
|
82
|
+
* Legacy: adas_<32hex> (no tenant either)
|
|
83
|
+
*
|
|
84
|
+
* ORDER IS LOAD-BEARING, and it is the order Core resolves in. base64url
|
|
85
|
+
* includes `-` and `_`, so a long-tenant key with a malformed secret has the
|
|
86
|
+
* SHAPE of a sealed blob. Trying the strict forms first means every well-formed
|
|
87
|
+
* key is claimed by the form it belongs to, and only genuine leftovers reach the
|
|
88
|
+
* blob pattern — where they parse as "sealed", fail to decrypt at Core, and
|
|
89
|
+
* authenticate as NOBODY. That residue is acceptable only because nothing here
|
|
90
|
+
* makes an authorisation decision. Reverse the order and a typo masquerades as
|
|
91
|
+
* a sealed key.
|
|
92
|
+
*
|
|
93
|
+
* `tenant: null` with `sealed: true` is the CORRECT answer, not a failure.
|
|
94
|
+
*
|
|
95
|
+
* `env: null` means the key does not SAY which environment it belongs to — not
|
|
96
|
+
* that it is production. Nothing here defaults it; a caller that needs to know
|
|
97
|
+
* must treat null as unknown.
|
|
98
|
+
*
|
|
99
|
+
* @returns {{ env: string|null, tenant: string|null, sealed: boolean, isValid: boolean }}
|
|
46
100
|
*/
|
|
47
101
|
export function parseApiKey(key) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
102
|
+
const no = { env: null, tenant: null, sealed: false, isValid: false };
|
|
103
|
+
if (!key || typeof key !== 'string') return no;
|
|
104
|
+
const withEnv = key.match(ENV_KEY_RE);
|
|
105
|
+
if (withEnv) return { env: withEnv[1], tenant: withEnv[2], sealed: false, isValid: true };
|
|
106
|
+
const match = key.match(PLAIN_KEY_RE);
|
|
107
|
+
if (match) return { env: null, tenant: match[1], sealed: false, isValid: true };
|
|
51
108
|
const legacy = key.match(/^adas_([0-9a-f]{32})$/);
|
|
52
|
-
if (legacy) return { tenant: null, isValid: true };
|
|
53
|
-
|
|
109
|
+
if (legacy) return { env: null, tenant: null, sealed: false, isValid: true };
|
|
110
|
+
const sealed = key.match(SEALED_KEY_RE);
|
|
111
|
+
if (sealed) return { env: sealed[1], tenant: null, sealed: true, isValid: true };
|
|
112
|
+
return no;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* ASK WHO THIS KEY IS. The replacement for splitting the string.
|
|
117
|
+
*
|
|
118
|
+
* Deliberately a bare fetch rather than request(): it runs BEFORE the session
|
|
119
|
+
* has credentials, which is the whole point — request() builds its headers from
|
|
120
|
+
* the session we are trying to populate.
|
|
121
|
+
*
|
|
122
|
+
* Returns { tenant, env } or throws. It does NOT fall back to anything. A key
|
|
123
|
+
* whose tenant cannot be established is a key we refuse to act for: guessing
|
|
124
|
+
* here would put a caller on someone else's data, which is the single failure
|
|
125
|
+
* this system must never have.
|
|
126
|
+
*
|
|
127
|
+
* `/auth/whoami` is served by the skill-validator (api.ateam-ai.com and
|
|
128
|
+
* dev-api.ateam-ai.com are BOTH the validator, not Core), which relays the
|
|
129
|
+
* tenant Core gave it when it verified the key. Same answer, one hop.
|
|
130
|
+
*/
|
|
131
|
+
export async function whoami(apiKey, baseUrl, { timeoutMs = 10_000 } = {}) {
|
|
132
|
+
if (!apiKey) throw new Error("whoami: no api key");
|
|
133
|
+
if (!baseUrl) throw new Error("whoami: no base url");
|
|
134
|
+
const res = await fetch(`${String(baseUrl).replace(/\/+$/, "")}/auth/whoami`, {
|
|
135
|
+
headers: { "X-API-KEY": apiKey },
|
|
136
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
137
|
+
});
|
|
138
|
+
const text = await res.text().catch(() => "");
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
throw new Error(`whoami failed at ${baseUrl} (HTTP ${res.status}): ${text.slice(0, 300)}`);
|
|
141
|
+
}
|
|
142
|
+
let json;
|
|
143
|
+
try { json = JSON.parse(text); } catch { throw new Error(`whoami returned non-JSON from ${baseUrl}: ${text.slice(0, 200)}`); }
|
|
144
|
+
if (!json?.ok || !json?.tenant) {
|
|
145
|
+
throw new Error(`whoami did not name a tenant at ${baseUrl}: ${text.slice(0, 300)}`);
|
|
146
|
+
}
|
|
147
|
+
return { tenant: json.tenant, env: json.env ?? null };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The API base a key's environment names, or null when it names none. */
|
|
151
|
+
export function baseUrlForKeyEnv(key) {
|
|
152
|
+
const { env } = parseApiKey(key);
|
|
153
|
+
return env ? KEY_ENVIRONMENTS[env] : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Which known environment does this URL belong to? null = not a known host.
|
|
158
|
+
*
|
|
159
|
+
* Used to REFUSE a `url` that contradicts the key. Deliberately only recognises
|
|
160
|
+
* the known hosts: an unrecognised url (localhost, a staging box) is still
|
|
161
|
+
* allowed through, because the override exists for those — it just must not be
|
|
162
|
+
* a way to cross prod/dev by accident.
|
|
163
|
+
*/
|
|
164
|
+
export function envForBaseUrl(url) {
|
|
165
|
+
if (!url) return null;
|
|
166
|
+
const norm = String(url).replace(/\/+$/, "");
|
|
167
|
+
for (const [env, base] of Object.entries(KEY_ENVIRONMENTS)) {
|
|
168
|
+
if (norm === base) return env;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
54
171
|
}
|
|
55
172
|
|
|
56
173
|
/**
|
|
@@ -65,20 +182,42 @@ export function setSessionCredentials(sessionId, { tenant, apiKey, apiUrl, expli
|
|
|
65
182
|
const parsed = parseApiKey(apiKey);
|
|
66
183
|
if (parsed.tenant) resolvedTenant = parsed.tenant;
|
|
67
184
|
}
|
|
185
|
+
// A SEALED key legitimately carries no tenant, so "unresolved" here means two
|
|
186
|
+
// very different things and they must not share an outcome:
|
|
187
|
+
//
|
|
188
|
+
// sealed → the tenant is not IN the string and never will be. Null is the
|
|
189
|
+
// honest answer until whoami is asked. Requests still work: Core
|
|
190
|
+
// resolves the tenant from the key itself, and headers() simply
|
|
191
|
+
// omits X-ADAS-TENANT rather than sending a guess.
|
|
192
|
+
// not sealed → the key is malformed. Still a hard failure.
|
|
193
|
+
//
|
|
194
|
+
// The distinction is the whole discipline: we never INVENT a tenant, but
|
|
195
|
+
// "not stated yet" is not the same as "wrong", and conflating them would
|
|
196
|
+
// refuse every sealed key at the door.
|
|
197
|
+
const sealedKey = apiKey ? parseApiKey(apiKey).sealed : false;
|
|
68
198
|
// Fail loudly — silent fallback to "main" previously let malformed API keys
|
|
69
199
|
// or missing tenant args silently pivot all operations onto the wrong tenant.
|
|
70
200
|
// Matches the pattern we killed in ADAS connectors (memory-mcp, docs-index-mcp,
|
|
71
201
|
// nutrition-mcp) — `|| "default"` was the #1 source of cross-tenant leaks.
|
|
72
|
-
if (!resolvedTenant) {
|
|
202
|
+
if (!resolvedTenant && !sealedKey) {
|
|
73
203
|
throw new Error(
|
|
74
204
|
`setSessionCredentials: tenant could not be resolved for session ${sessionId} ` +
|
|
75
|
-
`(tenant arg ${tenant ? "present" : "missing"}, apiKey ${apiKey ? "present but malformed (expected adas_<
|
|
205
|
+
`(tenant arg ${tenant ? "present" : "missing"}, apiKey ${apiKey ? "present but malformed (expected adas_<env>_<key>)" : "absent"}). ` +
|
|
76
206
|
`Refusing to fall back to a default tenant.`
|
|
77
207
|
);
|
|
78
208
|
}
|
|
209
|
+
if (!resolvedTenant) {
|
|
210
|
+
console.warn(
|
|
211
|
+
`[Auth] Session ${sessionId} holds a sealed key whose tenant is not resolved yet. ` +
|
|
212
|
+
`Calls will still authenticate (the tenant is inside the key and Core resolves it); ` +
|
|
213
|
+
`anything that needs the tenant BY NAME must call whoami rather than assume one.`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
79
216
|
const existing = sessions.get(sessionId);
|
|
80
217
|
sessions.set(sessionId, {
|
|
81
|
-
tenant
|
|
218
|
+
// Never `undefined` — a missing tenant is an explicit null, so every reader
|
|
219
|
+
// sees "not resolved" rather than an absent property it might paper over.
|
|
220
|
+
tenant: resolvedTenant || null,
|
|
82
221
|
apiKey,
|
|
83
222
|
apiUrl: apiUrl || existing?.apiUrl || null,
|
|
84
223
|
authExplicit: explicit || existing?.authExplicit || false,
|
|
@@ -88,7 +227,7 @@ export function setSessionCredentials(sessionId, { tenant, apiKey, apiUrl, expli
|
|
|
88
227
|
});
|
|
89
228
|
const urlNote = apiUrl ? `, url: ${apiUrl}` : "";
|
|
90
229
|
const masterNote = masterKey ? ", MASTER MODE" : "";
|
|
91
|
-
console.log(`[Auth] Credentials set for session ${sessionId} (tenant: ${resolvedTenant}${explicit ? ", explicit" : ""}${urlNote}${masterNote})`);
|
|
230
|
+
console.log(`[Auth] Credentials set for session ${sessionId} (tenant: ${resolvedTenant || "unresolved — sealed key"}${explicit ? ", explicit" : ""}${urlNote}${masterNote})`);
|
|
92
231
|
}
|
|
93
232
|
|
|
94
233
|
/**
|
|
@@ -135,10 +274,15 @@ export function getCredentials(sessionId) {
|
|
|
135
274
|
// If apiKey is present but tenant couldn't be derived, the key is malformed.
|
|
136
275
|
// Previously fell back to "main" — this silently routed credentials to the
|
|
137
276
|
// wrong tenant. Now we fail loudly.
|
|
138
|
-
|
|
277
|
+
//
|
|
278
|
+
// UNLESS THE KEY IS SEALED, where no tenant in the string is the design and
|
|
279
|
+
// not a defect. Same split as setSessionCredentials: "not stated" is not
|
|
280
|
+
// "wrong". Requests still authenticate, because the tenant is inside the key
|
|
281
|
+
// and Core reads it; headers() omits X-ADAS-TENANT rather than guessing one.
|
|
282
|
+
if (apiKey && !tenant && !parseApiKey(apiKey).sealed) {
|
|
139
283
|
throw new Error(
|
|
140
284
|
`getCredentials: apiKey is present (env ADAS_API_KEY) but tenant could not be resolved ` +
|
|
141
|
-
`(missing ADAS_TENANT env and apiKey is malformed — expected format adas_<
|
|
285
|
+
`(missing ADAS_TENANT env and apiKey is malformed — expected format adas_<env>_<key>). ` +
|
|
142
286
|
`Refusing to fall back to a default tenant.`
|
|
143
287
|
);
|
|
144
288
|
}
|
package/src/http.js
CHANGED
|
@@ -24,7 +24,7 @@ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
|
24
24
|
import express from "express";
|
|
25
25
|
import { createServer } from "./server.js";
|
|
26
26
|
import {
|
|
27
|
-
clearSession, setSessionCredentials, parseApiKey,
|
|
27
|
+
clearSession, setSessionCredentials, parseApiKey, whoami, baseUrlForKeyEnv, getCredentials,
|
|
28
28
|
startSessionSweeper, getSessionStats, sweepStaleSessions,
|
|
29
29
|
bindSessionBearer, getAuthOverride, getSessionBearer, bearerOwnershipOk,
|
|
30
30
|
} from "./api.js";
|
|
@@ -302,7 +302,7 @@ export function startHttpServer(port = 3100) {
|
|
|
302
302
|
if (sessionId && transports[sessionId]) {
|
|
303
303
|
// Reuse existing session — seed credentials if Bearer token present
|
|
304
304
|
transport = transports[sessionId];
|
|
305
|
-
seedCredentials(req, sessionId);
|
|
305
|
+
await seedCredentials(req, sessionId);
|
|
306
306
|
} else if (isInitializeRequest(req.body) || (sessionId && !transports[sessionId])) {
|
|
307
307
|
// New session, OR stale session with any request type (server restart recovery).
|
|
308
308
|
// Many MCP clients (Claude mobile, Claude Code) cache the session ID and fail to
|
|
@@ -326,7 +326,7 @@ export function startHttpServer(port = 3100) {
|
|
|
326
326
|
const newSessionId = sessionId || randomUUID();
|
|
327
327
|
|
|
328
328
|
// Seed credentials from OAuth Bearer token before server starts
|
|
329
|
-
seedCredentials(req, newSessionId);
|
|
329
|
+
await seedCredentials(req, newSessionId);
|
|
330
330
|
|
|
331
331
|
transport = new StreamableHTTPServerTransport({
|
|
332
332
|
sessionIdGenerator: () => newSessionId,
|
|
@@ -478,7 +478,7 @@ export function startHttpServer(port = 3100) {
|
|
|
478
478
|
* If the user previously called ateam_auth to override (e.g., switch tenants),
|
|
479
479
|
* that override is stored per bearer and takes priority here.
|
|
480
480
|
*/
|
|
481
|
-
function seedCredentials(req, sessionId) {
|
|
481
|
+
async function seedCredentials(req, sessionId) {
|
|
482
482
|
const token = req.auth?.token;
|
|
483
483
|
if (!token) return;
|
|
484
484
|
|
|
@@ -501,7 +501,35 @@ function seedCredentials(req, sessionId) {
|
|
|
501
501
|
// redundant ateam_auth call. (The env-var guard in tools.js is unaffected —
|
|
502
502
|
// env creds never flow through here; this path only fires for a real Bearer.)
|
|
503
503
|
const parsed = parseApiKey(token);
|
|
504
|
-
if (parsed.isValid)
|
|
505
|
-
|
|
504
|
+
if (!parsed.isValid) return;
|
|
505
|
+
|
|
506
|
+
// The key's own environment decides the base. Without this a dev bearer
|
|
507
|
+
// silently used the process default, which is PRODUCTION — the same
|
|
508
|
+
// never-guess-the-environment rule ateam_auth already follows, applied to the
|
|
509
|
+
// path that had been missed.
|
|
510
|
+
const apiUrl = baseUrlForKeyEnv(token) || undefined;
|
|
511
|
+
|
|
512
|
+
if (parsed.tenant) {
|
|
513
|
+
setSessionCredentials(sessionId, { tenant: parsed.tenant, apiKey: token, apiUrl, explicit: true });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// A SEALED key does not spell out its tenant, so it has to be asked for —
|
|
518
|
+
// ONCE. This runs on every request for an existing session, so without the
|
|
519
|
+
// guard below it would be a network round trip per MCP call.
|
|
520
|
+
const current = (() => { try { return getCredentials(sessionId); } catch { return null; } })();
|
|
521
|
+
if (current?.tenant && current.apiKey === token) return;
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
const me = await whoami(token, apiUrl);
|
|
525
|
+
setSessionCredentials(sessionId, { tenant: me.tenant, apiKey: token, apiUrl, explicit: true });
|
|
526
|
+
} catch (err) {
|
|
527
|
+
// Credentials are still set: the tenant lives INSIDE the key and Core reads
|
|
528
|
+
// it, so calls authenticate correctly with no X-ADAS-TENANT header at all.
|
|
529
|
+
// What we must not do is fill the gap with a guess — an unresolved tenant
|
|
530
|
+
// is recorded as null and retried on the next request. Anything that needs
|
|
531
|
+
// the name will say it does not have it.
|
|
532
|
+
console.warn(`[Auth] whoami failed for session ${sessionId} at ${apiUrl}: ${err.message} — proceeding with the tenant unresolved, never assumed.`);
|
|
533
|
+
setSessionCredentials(sessionId, { tenant: null, apiKey: token, apiUrl, explicit: true });
|
|
506
534
|
}
|
|
507
535
|
}
|
package/src/tools.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import {
|
|
12
12
|
get, post, patch, del,
|
|
13
13
|
setSessionCredentials, isAuthenticated, isExplicitlyAuthenticated,
|
|
14
|
-
getCredentials, parseApiKey, touchSession, getSessionContext,
|
|
14
|
+
getCredentials, parseApiKey, whoami, baseUrlForKeyEnv, envForBaseUrl, touchSession, getSessionContext,
|
|
15
15
|
setAuthOverride, switchTenant, isMasterMode, listTenants, getWhere, getBaseUrl,
|
|
16
16
|
} from "./api.js";
|
|
17
17
|
|
|
@@ -3176,11 +3176,25 @@ function chainTreeOf(resp) {
|
|
|
3176
3176
|
// is the difference between proving behaviour and matching a string that a
|
|
3177
3177
|
// rename would quietly satisfy.
|
|
3178
3178
|
export const handlers = {
|
|
3179
|
-
|
|
3179
|
+
// (_args, sid) — the SESSION ID IS LOAD-BEARING HERE.
|
|
3180
|
+
//
|
|
3181
|
+
// getBaseUrl(sessionId) resolves per-session first (api.js:326-339): a caller
|
|
3182
|
+
// that passed `url` to ateam_auth is talking to THAT api, and the session
|
|
3183
|
+
// holds it. Bootstrap called it with NO argument, so it skipped the
|
|
3184
|
+
// per-session and bearer branches every time and reported the process
|
|
3185
|
+
// DEFAULT — https://api.ateam-ai.com.
|
|
3186
|
+
//
|
|
3187
|
+
// The result was a tool that says PROD while the session is authenticated to
|
|
3188
|
+
// DEV, in the one field whose whole job is to tell you which environment you
|
|
3189
|
+
// are on. Observed live: a session working entirely against
|
|
3190
|
+
// dev-api.ateam-ai.com was told base_url: https://api.ateam-ai.com, and had
|
|
3191
|
+
// to learn from deploy errors which environment it was actually on. The
|
|
3192
|
+
// dangerous direction is the mirror image — believing you are on dev.
|
|
3193
|
+
ateam_bootstrap: async (_args, sid) => ({
|
|
3180
3194
|
runtime: {
|
|
3181
3195
|
ateam_mcp_version: MCP_VERSION,
|
|
3182
|
-
base_url: getBaseUrl(),
|
|
3183
|
-
_note: "The version of the ateam-mcp process actually serving this call, and the API
|
|
3196
|
+
base_url: getBaseUrl(sid),
|
|
3197
|
+
_note: "The version of the ateam-mcp process actually serving this call, and the API THIS SESSION talks to (per-session, as set by ateam_auth's `url`). If a fix looks missing, check this FIRST — a local MCP process keeps running the code it loaded at session start, so a pushed/published fix is not live until the process restarts.",
|
|
3184
3198
|
},
|
|
3185
3199
|
platform_positioning: {
|
|
3186
3200
|
name: "A-Team",
|
|
@@ -3514,22 +3528,76 @@ export const handlers = {
|
|
|
3514
3528
|
if (!api_key) {
|
|
3515
3529
|
return { ok: false, message: "Provide either api_key or master_key." };
|
|
3516
3530
|
}
|
|
3517
|
-
//
|
|
3518
|
-
//
|
|
3519
|
-
//
|
|
3520
|
-
|
|
3531
|
+
// ── THE KEY NAMES ITS ENVIRONMENT ──────────────────────────────────────
|
|
3532
|
+
//
|
|
3533
|
+
// One public MCP endpoint, and until now nothing about a session said which
|
|
3534
|
+
// environment it was on: the caller passed `url` or silently got the prod
|
|
3535
|
+
// default. A dev key at the prod base is just a 401, diagnosed after the
|
|
3536
|
+
// fact (see the hint below) and never prevented. The process starts at the
|
|
3537
|
+
// key, so the key carries the environment.
|
|
3538
|
+
//
|
|
3539
|
+
// NO FALLBACK, in either direction. A key that names an environment routes
|
|
3540
|
+
// there and NOWHERE else; if that backend rejects it, that is the answer.
|
|
3541
|
+
// Retrying the sibling host is how a dev key deploys to production.
|
|
3542
|
+
const keyEnv = parseApiKey(api_key).env;
|
|
3543
|
+
const explicitUrl = url ? url.replace(/\/+$/, "") : undefined;
|
|
3544
|
+
|
|
3545
|
+
// An explicit url that CONTRADICTS the key is refused here, locally, before
|
|
3546
|
+
// any network call. The override still exists for unusual hosts (localhost,
|
|
3547
|
+
// a staging box) — envForBaseUrl only recognises the known prod/dev hosts,
|
|
3548
|
+
// so anything else passes through untouched. What it must never be is a way
|
|
3549
|
+
// to cross environments by accident.
|
|
3550
|
+
if (keyEnv && explicitUrl) {
|
|
3551
|
+
const urlEnv = envForBaseUrl(explicitUrl);
|
|
3552
|
+
if (urlEnv && urlEnv !== keyEnv) {
|
|
3553
|
+
return {
|
|
3554
|
+
ok: false,
|
|
3555
|
+
message: `Refusing to authenticate: this key names the "${keyEnv}" environment, but url points at "${urlEnv}" (${explicitUrl}). One of them is wrong, and guessing which would mean operating on the wrong system. Drop the url argument to use the key's own environment, or use a key for "${urlEnv}".`,
|
|
3556
|
+
};
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
const apiUrl = explicitUrl || baseUrlForKeyEnv(api_key) || undefined;
|
|
3561
|
+
|
|
3562
|
+
// ── WHO IS THIS KEY? ───────────────────────────────────────────────────
|
|
3563
|
+
//
|
|
3564
|
+
// It used to be answered by splitting the string, which is exactly why the
|
|
3565
|
+
// customer's name travelled inside the credential — into logs, screenshots,
|
|
3566
|
+
// support tickets. A SEALED key (`adas_<env>_<blob>`) does not carry it, so
|
|
3567
|
+
// we ASK. Order of preference, and nothing beyond it:
|
|
3568
|
+
//
|
|
3569
|
+
// 1. an explicit `tenant` argument
|
|
3570
|
+
// 2. the tenant the key still spells out (older formats)
|
|
3571
|
+
// 3. GET /auth/whoami
|
|
3572
|
+
//
|
|
3573
|
+
// A sealed key whose whoami fails is REFUSED. Not "authenticated without a
|
|
3574
|
+
// tenant", not retried elsewhere: ateam_auth is the moment a session learns
|
|
3575
|
+
// who it is, and half-knowing is how a caller ends up acting on the wrong
|
|
3576
|
+
// account. Nothing here invents a tenant under any circumstances.
|
|
3577
|
+
let resolvedTenant = tenant || parseApiKey(api_key).tenant;
|
|
3521
3578
|
if (!resolvedTenant) {
|
|
3522
|
-
const
|
|
3523
|
-
|
|
3579
|
+
const base = apiUrl || getBaseUrl(sessionId);
|
|
3580
|
+
try {
|
|
3581
|
+
const me = await whoami(api_key, base);
|
|
3582
|
+
resolvedTenant = me.tenant;
|
|
3583
|
+
} catch (err) {
|
|
3584
|
+
return {
|
|
3585
|
+
ok: false,
|
|
3586
|
+
message:
|
|
3587
|
+
`This key does not name its tenant — the tenant is sealed inside it and only the server can read it — ` +
|
|
3588
|
+
`and ${base} could not tell me who you are: ${err.message} ` +
|
|
3589
|
+
`Nothing was authenticated: acting on a guessed tenant is the one failure this must never have. ` +
|
|
3590
|
+
`If that host is an older deployment without /auth/whoami, upgrade it or pass tenant: "<name>" explicitly.`,
|
|
3591
|
+
};
|
|
3592
|
+
}
|
|
3524
3593
|
}
|
|
3525
3594
|
if (!resolvedTenant) {
|
|
3526
3595
|
return {
|
|
3527
3596
|
ok: false,
|
|
3528
|
-
message: `Could not resolve tenant from api_key (expected format: adas_<
|
|
3597
|
+
message: `Could not resolve tenant from api_key (expected format: adas_<env>_<key>). Pass the "tenant" arg explicitly, or check that your API key is well-formed.`,
|
|
3529
3598
|
};
|
|
3530
3599
|
}
|
|
3531
|
-
|
|
3532
|
-
const apiUrl = url ? url.replace(/\/+$/, "") : undefined;
|
|
3600
|
+
|
|
3533
3601
|
setSessionCredentials(sessionId, { tenant: resolvedTenant, apiKey: api_key, apiUrl, explicit: true });
|
|
3534
3602
|
// Persist override per bearer (survives session changes)
|
|
3535
3603
|
setAuthOverride(sessionId, { tenant: resolvedTenant, apiKey: api_key, apiUrl });
|
|
@@ -3540,6 +3608,23 @@ export const handlers = {
|
|
|
3540
3608
|
return {
|
|
3541
3609
|
ok: true,
|
|
3542
3610
|
tenant: resolvedTenant,
|
|
3611
|
+
// The environment is part of WHO YOU ARE NOW, so it is reported here and
|
|
3612
|
+
// in ateam_bootstrap.runtime, from the same resolution — one question,
|
|
3613
|
+
// one answer.
|
|
3614
|
+
//
|
|
3615
|
+
// A KEY THAT NAMES NO ENVIRONMENT GETS NO CLAIM. Until keys are
|
|
3616
|
+
// recreated, a legacy `adas_<tenant>_<hex>` still authenticates and
|
|
3617
|
+
// still lands on the process default — which is PRODUCTION. That
|
|
3618
|
+
// routing predates this change and is not made worse by it, but
|
|
3619
|
+
// reporting it as `environment: "prod"` would be: it would turn an
|
|
3620
|
+
// unstated default into a confident assertion, which is the exact
|
|
3621
|
+
// failure this whole change exists to remove. So the field says
|
|
3622
|
+
// `unstated`, and the note says which base was used and why.
|
|
3623
|
+
environment: keyEnv || (explicitUrl ? envForBaseUrl(explicitUrl) : null) || "unstated",
|
|
3624
|
+
...(!keyEnv && !explicitUrl && {
|
|
3625
|
+
environment_note: `This key does not name an environment, so the process default was used (${getBaseUrl(sessionId)}). Recreate it as adas_<env>_<tenant>_<hex> to make the environment explicit — until then nothing here can confirm which system you are on.`,
|
|
3626
|
+
}),
|
|
3627
|
+
base_url: getBaseUrl(sessionId),
|
|
3543
3628
|
message: `Authenticated to tenant "${resolvedTenant}"${urlNote}. ${result.solutions?.length || 0} solution(s) found.`,
|
|
3544
3629
|
};
|
|
3545
3630
|
} catch (err) {
|
|
@@ -3548,7 +3633,8 @@ export const handlers = {
|
|
|
3548
3633
|
// versa). Surface the base we tried and, if it looks like that mismatch,
|
|
3549
3634
|
// hint the dev-api retry — instead of a generic "invalid/unconfigured key".
|
|
3550
3635
|
const base = getBaseUrl(sessionId) || "";
|
|
3551
|
-
const
|
|
3636
|
+
const parsedKey = parseApiKey(api_key);
|
|
3637
|
+
const wellFormedKey = parsedKey.isValid;
|
|
3552
3638
|
const triedProd = /(?:^|\/\/)api\.ateam-ai\.com/.test(base);
|
|
3553
3639
|
// THE HEADLINE MUST NOT CONTRADICT THE HINT. The upstream message is
|
|
3554
3640
|
// "Invalid or unconfigured API key" — which for a well-formed key tried
|
|
@@ -3557,7 +3643,12 @@ export const handlers = {
|
|
|
3557
3643
|
// did not need. The hint below already said the right thing and rescued a
|
|
3558
3644
|
// session on 2026-08-21, but only because someone read past the first
|
|
3559
3645
|
// line. Lead with the likely cause; keep the upstream text as detail.
|
|
3560
|
-
|
|
3646
|
+
// A key that NAMES its environment cannot be in the wrong one — routing
|
|
3647
|
+
// came from the key itself, and a contradicting url was refused above. So
|
|
3648
|
+
// this hint is only for the older no-env keys that still land on the prod
|
|
3649
|
+
// default. Offering it for an env-bearing key would send the reader
|
|
3650
|
+
// chasing an environment mismatch that the format has already ruled out.
|
|
3651
|
+
if (wellFormedKey && triedProd && !parsedKey.env) {
|
|
3561
3652
|
return {
|
|
3562
3653
|
ok: false,
|
|
3563
3654
|
tenant: resolvedTenant,
|