@ateam-ai/mcp 0.4.91 → 0.4.93
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 +64 -28
- package/src/tools.js +89 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ateam-ai/mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.93",
|
|
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 && node --test test/bootstrap-base-url.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";
|
|
@@ -180,24 +180,34 @@ export function startHttpServer(port = 3100) {
|
|
|
180
180
|
next();
|
|
181
181
|
};
|
|
182
182
|
|
|
183
|
-
// Bearer auth middleware
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
183
|
+
// Bearer auth middleware for MCP routes — STRICT ON BOTH PATHS.
|
|
184
|
+
//
|
|
185
|
+
// "/mcp" used to be optional-auth (704206e): validate a Bearer if one is
|
|
186
|
+
// present, otherwise let the request through so the caller could authenticate
|
|
187
|
+
// in-band with the ateam_auth tool. That reads as permissive, and it is —
|
|
188
|
+
// but permissiveness is not free, because SILENCE IS AN ANSWER TO A CLIENT.
|
|
189
|
+
//
|
|
190
|
+
// An OAuth client discovers that it must send a token by being REFUSED one
|
|
191
|
+
// that lacks it: 401 plus WWW-Authenticate pointing at the resource metadata
|
|
192
|
+
// (RFC 9728, and what the MCP authorization spec builds on). Answering 200 to
|
|
193
|
+
// an anonymous request tells the client the endpoint is public, so it never
|
|
194
|
+
// attaches the token it is holding.
|
|
195
|
+
//
|
|
196
|
+
// That is exactly what happened to ChatGPT, measured rather than guessed:
|
|
197
|
+
// 107 requests to /mcp, ZERO carrying an Authorization header, while the
|
|
198
|
+
// connector had completed OAuth and held a valid token. Every session was
|
|
199
|
+
// anonymous, so every tenant tool refused, and ChatGPT disabled the connector
|
|
200
|
+
// — a failure that looks like a broken server and is actually a server that
|
|
201
|
+
// never asked. The token was there the whole time.
|
|
202
|
+
//
|
|
203
|
+
// The in-band ateam_auth path is NOT lost: a client authenticates with its
|
|
204
|
+
// bearer and may still call ateam_auth to switch tenants or point at another
|
|
205
|
+
// environment. What is gone is authenticating with nothing at all, which
|
|
206
|
+
// never worked for an OAuth client anyway — it only looked like it did.
|
|
207
|
+
const mcpAuth = bearerMiddleware
|
|
187
208
|
? [autoInjectToken, bearerMiddleware]
|
|
188
209
|
: [];
|
|
189
210
|
|
|
190
|
-
// Optional auth: if Bearer token present, validate it (sets req.auth for seedCredentials).
|
|
191
|
-
// If no token, let the request through — user can authenticate via ateam_auth tool.
|
|
192
|
-
const optionalBearerAuth = bearerMiddleware
|
|
193
|
-
? (req, res, next) => {
|
|
194
|
-
if (!req.headers.authorization) return next();
|
|
195
|
-
bearerMiddleware(req, res, next);
|
|
196
|
-
}
|
|
197
|
-
: (_req, _res, next) => next();
|
|
198
|
-
|
|
199
|
-
const mcpAuthOptional = [autoInjectToken, optionalBearerAuth];
|
|
200
|
-
|
|
201
211
|
// ─── CORS — required for browser-based MCP clients ──────────────
|
|
202
212
|
// Origin allowlist (round 014 security hardening).
|
|
203
213
|
// ATEAM_CORS_ALLOWED_ORIGINS env = comma-separated list, or "*" / unset for
|
|
@@ -302,7 +312,7 @@ export function startHttpServer(port = 3100) {
|
|
|
302
312
|
if (sessionId && transports[sessionId]) {
|
|
303
313
|
// Reuse existing session — seed credentials if Bearer token present
|
|
304
314
|
transport = transports[sessionId];
|
|
305
|
-
seedCredentials(req, sessionId);
|
|
315
|
+
await seedCredentials(req, sessionId);
|
|
306
316
|
} else if (isInitializeRequest(req.body) || (sessionId && !transports[sessionId])) {
|
|
307
317
|
// New session, OR stale session with any request type (server restart recovery).
|
|
308
318
|
// Many MCP clients (Claude mobile, Claude Code) cache the session ID and fail to
|
|
@@ -326,7 +336,7 @@ export function startHttpServer(port = 3100) {
|
|
|
326
336
|
const newSessionId = sessionId || randomUUID();
|
|
327
337
|
|
|
328
338
|
// Seed credentials from OAuth Bearer token before server starts
|
|
329
|
-
seedCredentials(req, newSessionId);
|
|
339
|
+
await seedCredentials(req, newSessionId);
|
|
330
340
|
|
|
331
341
|
transport = new StreamableHTTPServerTransport({
|
|
332
342
|
sessionIdGenerator: () => newSessionId,
|
|
@@ -418,14 +428,12 @@ export function startHttpServer(port = 3100) {
|
|
|
418
428
|
await transports[sessionId].handleRequest(req, res);
|
|
419
429
|
};
|
|
420
430
|
|
|
421
|
-
// Mount MCP handlers at both "/" and "/mcp"
|
|
422
|
-
//
|
|
423
|
-
// "/mcp" (ChatGPT): optional auth — accepts OAuth OR ateam_auth tool
|
|
431
|
+
// Mount MCP handlers at both "/" (Claude.ai) and "/mcp" (ChatGPT).
|
|
432
|
+
// ONE auth rule for both — see mcpAuth above for why the split was removed.
|
|
424
433
|
for (const path of MCP_PATHS) {
|
|
425
|
-
|
|
426
|
-
app.
|
|
427
|
-
app.
|
|
428
|
-
app.delete(path, ...auth, mcpDelete);
|
|
434
|
+
app.post(path, ...mcpAuth, mcpPost);
|
|
435
|
+
app.get(path, ...mcpAuth, mcpGet);
|
|
436
|
+
app.delete(path, ...mcpAuth, mcpDelete);
|
|
429
437
|
}
|
|
430
438
|
|
|
431
439
|
// ─── Catch-all: log unhandled requests ──────────────────────────
|
|
@@ -478,7 +486,7 @@ export function startHttpServer(port = 3100) {
|
|
|
478
486
|
* If the user previously called ateam_auth to override (e.g., switch tenants),
|
|
479
487
|
* that override is stored per bearer and takes priority here.
|
|
480
488
|
*/
|
|
481
|
-
function seedCredentials(req, sessionId) {
|
|
489
|
+
async function seedCredentials(req, sessionId) {
|
|
482
490
|
const token = req.auth?.token;
|
|
483
491
|
if (!token) return;
|
|
484
492
|
|
|
@@ -501,7 +509,35 @@ function seedCredentials(req, sessionId) {
|
|
|
501
509
|
// redundant ateam_auth call. (The env-var guard in tools.js is unaffected —
|
|
502
510
|
// env creds never flow through here; this path only fires for a real Bearer.)
|
|
503
511
|
const parsed = parseApiKey(token);
|
|
504
|
-
if (parsed.isValid)
|
|
505
|
-
|
|
512
|
+
if (!parsed.isValid) return;
|
|
513
|
+
|
|
514
|
+
// The key's own environment decides the base. Without this a dev bearer
|
|
515
|
+
// silently used the process default, which is PRODUCTION — the same
|
|
516
|
+
// never-guess-the-environment rule ateam_auth already follows, applied to the
|
|
517
|
+
// path that had been missed.
|
|
518
|
+
const apiUrl = baseUrlForKeyEnv(token) || undefined;
|
|
519
|
+
|
|
520
|
+
if (parsed.tenant) {
|
|
521
|
+
setSessionCredentials(sessionId, { tenant: parsed.tenant, apiKey: token, apiUrl, explicit: true });
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// A SEALED key does not spell out its tenant, so it has to be asked for —
|
|
526
|
+
// ONCE. This runs on every request for an existing session, so without the
|
|
527
|
+
// guard below it would be a network round trip per MCP call.
|
|
528
|
+
const current = (() => { try { return getCredentials(sessionId); } catch { return null; } })();
|
|
529
|
+
if (current?.tenant && current.apiKey === token) return;
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
const me = await whoami(token, apiUrl);
|
|
533
|
+
setSessionCredentials(sessionId, { tenant: me.tenant, apiKey: token, apiUrl, explicit: true });
|
|
534
|
+
} catch (err) {
|
|
535
|
+
// Credentials are still set: the tenant lives INSIDE the key and Core reads
|
|
536
|
+
// it, so calls authenticate correctly with no X-ADAS-TENANT header at all.
|
|
537
|
+
// What we must not do is fill the gap with a guess — an unresolved tenant
|
|
538
|
+
// is recorded as null and retried on the next request. Anything that needs
|
|
539
|
+
// the name will say it does not have it.
|
|
540
|
+
console.warn(`[Auth] whoami failed for session ${sessionId} at ${apiUrl}: ${err.message} — proceeding with the tenant unresolved, never assumed.`);
|
|
541
|
+
setSessionCredentials(sessionId, { tenant: null, apiKey: token, apiUrl, explicit: true });
|
|
506
542
|
}
|
|
507
543
|
}
|
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
|
|
|
@@ -3528,22 +3528,76 @@ export const handlers = {
|
|
|
3528
3528
|
if (!api_key) {
|
|
3529
3529
|
return { ok: false, message: "Provide either api_key or master_key." };
|
|
3530
3530
|
}
|
|
3531
|
-
//
|
|
3532
|
-
//
|
|
3533
|
-
//
|
|
3534
|
-
|
|
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;
|
|
3535
3578
|
if (!resolvedTenant) {
|
|
3536
|
-
const
|
|
3537
|
-
|
|
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
|
+
}
|
|
3538
3593
|
}
|
|
3539
3594
|
if (!resolvedTenant) {
|
|
3540
3595
|
return {
|
|
3541
3596
|
ok: false,
|
|
3542
|
-
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.`,
|
|
3543
3598
|
};
|
|
3544
3599
|
}
|
|
3545
|
-
|
|
3546
|
-
const apiUrl = url ? url.replace(/\/+$/, "") : undefined;
|
|
3600
|
+
|
|
3547
3601
|
setSessionCredentials(sessionId, { tenant: resolvedTenant, apiKey: api_key, apiUrl, explicit: true });
|
|
3548
3602
|
// Persist override per bearer (survives session changes)
|
|
3549
3603
|
setAuthOverride(sessionId, { tenant: resolvedTenant, apiKey: api_key, apiUrl });
|
|
@@ -3554,6 +3608,23 @@ export const handlers = {
|
|
|
3554
3608
|
return {
|
|
3555
3609
|
ok: true,
|
|
3556
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),
|
|
3557
3628
|
message: `Authenticated to tenant "${resolvedTenant}"${urlNote}. ${result.solutions?.length || 0} solution(s) found.`,
|
|
3558
3629
|
};
|
|
3559
3630
|
} catch (err) {
|
|
@@ -3562,7 +3633,8 @@ export const handlers = {
|
|
|
3562
3633
|
// versa). Surface the base we tried and, if it looks like that mismatch,
|
|
3563
3634
|
// hint the dev-api retry — instead of a generic "invalid/unconfigured key".
|
|
3564
3635
|
const base = getBaseUrl(sessionId) || "";
|
|
3565
|
-
const
|
|
3636
|
+
const parsedKey = parseApiKey(api_key);
|
|
3637
|
+
const wellFormedKey = parsedKey.isValid;
|
|
3566
3638
|
const triedProd = /(?:^|\/\/)api\.ateam-ai\.com/.test(base);
|
|
3567
3639
|
// THE HEADLINE MUST NOT CONTRADICT THE HINT. The upstream message is
|
|
3568
3640
|
// "Invalid or unconfigured API key" — which for a well-formed key tried
|
|
@@ -3571,7 +3643,12 @@ export const handlers = {
|
|
|
3571
3643
|
// did not need. The hint below already said the right thing and rescued a
|
|
3572
3644
|
// session on 2026-08-21, but only because someone read past the first
|
|
3573
3645
|
// line. Lead with the likely cause; keep the upstream text as detail.
|
|
3574
|
-
|
|
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) {
|
|
3575
3652
|
return {
|
|
3576
3653
|
ok: false,
|
|
3577
3654
|
tenant: resolvedTenant,
|