@ateam-ai/mcp 0.1.10 → 0.1.12

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.1.10",
3
+ "version": "0.1.12",
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
@@ -2,7 +2,7 @@
2
2
  * A-Team API client — thin HTTP wrapper for the External Agent API.
3
3
  *
4
4
  * Credentials resolve in this order:
5
- * 1. Per-session override (set via adas_auth tool — used by HTTP transport)
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
8
  */
@@ -17,6 +17,12 @@ const REQUEST_TIMEOUT_MS = 30_000;
17
17
  // Per-session credential store (sessionId → { tenant, apiKey })
18
18
  const sessions = new Map();
19
19
 
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
25
+
20
26
  /**
21
27
  * Parse a tenant-embedded API key.
22
28
  * Format: adas_<tenant>_<32hex>
@@ -33,8 +39,9 @@ export function parseApiKey(key) {
33
39
  }
34
40
 
35
41
  /**
36
- * Set credentials for a session (called by adas_auth tool).
42
+ * Set credentials for a session (called by ateam_auth tool).
37
43
  * If tenant is not provided, it's auto-extracted from the key.
44
+ * Also updates the global fallback so new sessions inherit credentials.
38
45
  */
39
46
  export function setSessionCredentials(sessionId, { tenant, apiKey }) {
40
47
  let resolvedTenant = tenant;
@@ -42,18 +49,32 @@ export function setSessionCredentials(sessionId, { tenant, apiKey }) {
42
49
  const parsed = parseApiKey(apiKey);
43
50
  if (parsed.tenant) resolvedTenant = parsed.tenant;
44
51
  }
45
- sessions.set(sessionId, { tenant: resolvedTenant || "main", apiKey });
52
+ const creds = { tenant: resolvedTenant || "main", apiKey };
53
+ sessions.set(sessionId, creds);
54
+
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})`);
46
58
  }
47
59
 
48
60
  /**
49
61
  * Get credentials for a session, falling back to env vars.
50
- * If using env var key with embedded tenant and no explicit ADAS_TENANT, auto-extract.
62
+ * Resolution order:
63
+ * 1. Per-session (from ateam_auth or seedCredentials)
64
+ * 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.
51
69
  */
52
70
  export function getCredentials(sessionId) {
71
+ // 1. Per-session credentials
53
72
  const session = sessionId ? sessions.get(sessionId) : null;
54
73
  if (session) {
55
74
  return { tenant: session.tenant, apiKey: session.apiKey };
56
75
  }
76
+
77
+ // 2. Environment variables
57
78
  const apiKey = ENV_API_KEY || "";
58
79
  let tenant = ENV_TENANT;
59
80
  if (!tenant && apiKey) {
@@ -63,6 +84,21 @@ export function getCredentials(sessionId) {
63
84
  return { tenant: tenant || "main", apiKey };
64
85
  }
65
86
 
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
+
66
102
  /**
67
103
  * Check if a session is authenticated (has an API key from any source).
68
104
  */
@@ -91,11 +127,11 @@ function headers(sessionId) {
91
127
  */
92
128
  function formatError(method, path, status, body) {
93
129
  const hints = {
94
- 401: "Your API key may be invalid or expired. Get a valid key at https://mcp.ateam-ai.com/get-api-key then call adas_auth(api_key: \"your_key\").",
130
+ 401: "Your API key may be invalid or expired. Get a valid key at https://mcp.ateam-ai.com/get-api-key then call ateam_auth(api_key: \"your_key\").",
95
131
  403: "You don't have permission for this operation. Check your tenant and API key. Get a key at https://mcp.ateam-ai.com/get-api-key",
96
- 404: "Resource not found. Check the solution_id or skill_id you're using. Use adas_list_solutions to see available solutions.",
132
+ 404: "Resource not found. Check the solution_id or skill_id you're using. Use ateam_list_solutions to see available solutions.",
97
133
  409: "Conflict — the resource may already exist or is in a conflicting state.",
98
- 422: "Validation failed. Check the request payload against the spec (use adas_get_spec).",
134
+ 422: "Validation failed. Check the request payload against the spec (use ateam_get_spec).",
99
135
  429: "Rate limited. Wait a moment and try again.",
100
136
  500: "A-Team server error. The platform may be temporarily unavailable. Try again in a minute.",
101
137
  502: "A-Team API is unreachable. The service may be restarting. Try again in a minute.",
@@ -114,22 +150,28 @@ function formatError(method, path, status, body) {
114
150
 
115
151
  /**
116
152
  * Core fetch wrapper with timeout and error formatting.
153
+ * @param {string} method
154
+ * @param {string} path
155
+ * @param {*} body
156
+ * @param {string} sessionId
157
+ * @param {{ timeoutMs?: number }} [opts]
117
158
  */
118
- async function request(method, path, body, sessionId) {
159
+ async function request(method, path, body, sessionId, opts = {}) {
160
+ const timeoutMs = opts.timeoutMs || REQUEST_TIMEOUT_MS;
119
161
  const controller = new AbortController();
120
- const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
162
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
121
163
 
122
164
  try {
123
- const opts = {
165
+ const fetchOpts = {
124
166
  method,
125
167
  headers: headers(sessionId),
126
168
  signal: controller.signal,
127
169
  };
128
170
  if (body !== undefined) {
129
- opts.body = JSON.stringify(body);
171
+ fetchOpts.body = JSON.stringify(body);
130
172
  }
131
173
 
132
- const res = await fetch(`${BASE_URL}${path}`, opts);
174
+ const res = await fetch(`${BASE_URL}${path}`, fetchOpts);
133
175
 
134
176
  if (!res.ok) {
135
177
  const text = await res.text().catch(() => "");
@@ -140,7 +182,7 @@ async function request(method, path, body, sessionId) {
140
182
  } catch (err) {
141
183
  if (err.name === "AbortError") {
142
184
  throw new Error(
143
- `A-Team API timeout: ${method} ${path} did not respond within ${REQUEST_TIMEOUT_MS / 1000}s.\n` +
185
+ `A-Team API timeout: ${method} ${path} did not respond within ${timeoutMs / 1000}s.\n` +
144
186
  `Hint: The A-Team API at ${BASE_URL} may be down. Check https://api.ateam-ai.com/health`
145
187
  );
146
188
  }
@@ -162,18 +204,18 @@ async function request(method, path, body, sessionId) {
162
204
  }
163
205
  }
164
206
 
165
- export async function get(path, sessionId) {
166
- return request("GET", path, undefined, sessionId);
207
+ export async function get(path, sessionId, opts) {
208
+ return request("GET", path, undefined, sessionId, opts);
167
209
  }
168
210
 
169
- export async function post(path, body, sessionId) {
170
- return request("POST", path, body, sessionId);
211
+ export async function post(path, body, sessionId, opts) {
212
+ return request("POST", path, body, sessionId, opts);
171
213
  }
172
214
 
173
- export async function patch(path, body, sessionId) {
174
- return request("PATCH", path, body, sessionId);
215
+ export async function patch(path, body, sessionId, opts) {
216
+ return request("PATCH", path, body, sessionId, opts);
175
217
  }
176
218
 
177
- export async function del(path, sessionId) {
178
- return request("DELETE", path, undefined, sessionId);
219
+ export async function del(path, sessionId, opts) {
220
+ return request("DELETE", path, undefined, sessionId, opts);
179
221
  }
package/src/http.js CHANGED
@@ -1,6 +1,21 @@
1
1
  /**
2
2
  * Streamable HTTP transport for ateam-mcp.
3
3
  * Enables ChatGPT and remote MCP clients to connect via HTTPS.
4
+ *
5
+ * MCP endpoint is served at BOTH "/" and "/mcp" because:
6
+ * - Claude.ai sends requests to the connector URL (root "/")
7
+ * - Claude Code and other clients may use "/mcp"
8
+ *
9
+ * OAuth2 (enabled by default):
10
+ * Serves /.well-known/*, /authorize, /token, /register endpoints.
11
+ * MCP routes require a Bearer token — triggers OAuth discovery in Claude.ai.
12
+ * Set ATEAM_OAUTH_DISABLED=1 to bypass (for ChatGPT or legacy clients).
13
+ *
14
+ * Token Auto-Injection:
15
+ * Claude.ai's OAuth client and MCP client don't share Bearer tokens.
16
+ * After a successful token exchange, we cache the token server-side and
17
+ * inject it into subsequent unauthenticated MCP requests. This is a
18
+ * simple cache lookup — no request holding, no polling, no flags.
4
19
  */
5
20
 
6
21
  import { randomUUID } from "node:crypto";
@@ -8,15 +23,150 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
8
23
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
9
24
  import express from "express";
10
25
  import { createServer } from "./server.js";
11
- import { clearSession } from "./api.js";
26
+ import { clearSession, setSessionCredentials, parseApiKey } from "./api.js";
27
+ import { mountOAuth } from "./oauth.js";
12
28
 
13
29
  // Active sessions
14
30
  const transports = {};
15
31
 
32
+ // MCP paths — Claude.ai uses "/" (connector URL), others may use "/mcp"
33
+ const MCP_PATHS = ["/", "/mcp"];
34
+
35
+ // Recently exchanged tokens — for auto-injection into MCP requests.
36
+ // Key: token string, Value: { token, createdAt }
37
+ const recentTokens = new Map();
38
+ const TOKEN_TTL = 60 * 60 * 1000; // 60 minutes
39
+
16
40
  export function startHttpServer(port = 3100) {
17
41
  const app = express();
42
+ app.set("trust proxy", 1); // behind Cloudflare tunnel
43
+
44
+ // ─── Request logging ────────────────────────────────────────────
45
+ app.use((req, res, next) => {
46
+ const url = req.originalUrl || req.url;
47
+ const start = Date.now();
48
+ const auth = req.headers.authorization;
49
+ console.log(`[HTTP] >>> ${req.method} ${url}${auth ? ` Auth: ${auth.substring(0, 30)}...` : ""}${MCP_PATHS.includes(url.split("?")[0]) ? ` Accept: ${req.headers.accept || "(none)"}` : ""}`);
50
+ res.on("finish", () => {
51
+ console.log(`[HTTP] <<< ${req.method} ${url} → ${res.statusCode} (${Date.now() - start}ms)`);
52
+ });
53
+ next();
54
+ });
55
+
18
56
  app.use(express.json());
19
57
 
58
+ // ─── Fix Accept header for MCP endpoints ──────────────────────────
59
+ // Claude.ai may not send the required Accept header with text/event-stream.
60
+ // The MCP SDK requires it per spec, so we inject it if missing.
61
+ // Must patch both parsed headers AND rawHeaders since @hono/node-server
62
+ // reads from rawHeaders when converting to Web Standard Request.
63
+ for (const path of MCP_PATHS) {
64
+ app.use(path, (req, _res, next) => {
65
+ const accept = req.headers.accept || "";
66
+ if (req.method === "POST" && !accept.includes("text/event-stream")) {
67
+ const fixed = "application/json, text/event-stream";
68
+ req.headers.accept = fixed;
69
+ const idx = req.rawHeaders.findIndex((h) => h.toLowerCase() === "accept");
70
+ if (idx !== -1) {
71
+ req.rawHeaders[idx + 1] = fixed;
72
+ } else {
73
+ req.rawHeaders.push("Accept", fixed);
74
+ }
75
+ }
76
+ next();
77
+ });
78
+ }
79
+
80
+ // ─── OAuth setup ────────────────────────────────────────────────
81
+ const oauthDisabled = process.env.ATEAM_OAUTH_DISABLED === "1";
82
+ const baseUrl = process.env.ATEAM_BASE_URL || "https://mcp.ateam-ai.com";
83
+
84
+ let bearerMiddleware = null;
85
+ if (!oauthDisabled) {
86
+ // ─── Token capture middleware — MUST be BEFORE mcpAuthRouter ───
87
+ // Intercepts POST /token responses to cache access_tokens for
88
+ // auto-injection. Placed before mountOAuth so it can monkey-patch
89
+ // res.json() before the SDK's token handler sends the response.
90
+ app.use("/token", (req, res, next) => {
91
+ if (req.method !== "POST") return next();
92
+ const origJson = res.json.bind(res);
93
+ res.json = (data) => {
94
+ if (data && data.access_token && res.statusCode >= 200 && res.statusCode < 300) {
95
+ recentTokens.set(data.access_token, {
96
+ token: data.access_token,
97
+ createdAt: Date.now(),
98
+ });
99
+ console.log(`[Auth] Cached token from /token response (${recentTokens.size} active)`);
100
+ // Prune expired
101
+ for (const [k, v] of recentTokens) {
102
+ if (Date.now() - v.createdAt > TOKEN_TTL) recentTokens.delete(k);
103
+ }
104
+ }
105
+ return origJson(data);
106
+ };
107
+ next();
108
+ });
109
+
110
+ const oauth = mountOAuth(app, baseUrl);
111
+ bearerMiddleware = oauth.bearerMiddleware;
112
+
113
+ console.log(` OAuth: enabled (issuer: ${baseUrl})`);
114
+ } else {
115
+ console.log(" OAuth: disabled (ATEAM_OAUTH_DISABLED=1)");
116
+ }
117
+
118
+ // ─── Token auto-injection middleware ────────────────────────────
119
+ // If a request has no Authorization header but we have a recently
120
+ // exchanged token, inject it. Simple cache lookup — never blocks.
121
+ const autoInjectToken = (req, _res, next) => {
122
+ if (req.headers.authorization) return next();
123
+ const token = getNewestToken();
124
+ if (token) {
125
+ req.headers.authorization = `Bearer ${token}`;
126
+ const idx = req.rawHeaders.findIndex((h) => h.toLowerCase() === "authorization");
127
+ if (idx !== -1) {
128
+ req.rawHeaders[idx + 1] = `Bearer ${token}`;
129
+ } else {
130
+ req.rawHeaders.push("Authorization", `Bearer ${token}`);
131
+ }
132
+ console.log(`[Auth] Auto-injected token into ${req.method} ${req.originalUrl || req.url}`);
133
+ }
134
+ next();
135
+ };
136
+
137
+ // Bearer auth middleware chains for MCP routes:
138
+ // - "/" (Claude.ai): strict OAuth — Bearer token required
139
+ // - "/mcp" (ChatGPT): optional OAuth — validate Bearer if present, pass through if not
140
+ const mcpAuthStrict = bearerMiddleware
141
+ ? [autoInjectToken, bearerMiddleware]
142
+ : [];
143
+
144
+ // Optional auth: if Bearer token present, validate it (sets req.auth for seedCredentials).
145
+ // If no token, let the request through — user can authenticate via ateam_auth tool.
146
+ const optionalBearerAuth = bearerMiddleware
147
+ ? (req, res, next) => {
148
+ if (!req.headers.authorization) return next();
149
+ bearerMiddleware(req, res, next);
150
+ }
151
+ : (_req, _res, next) => next();
152
+
153
+ const mcpAuthOptional = [autoInjectToken, optionalBearerAuth];
154
+
155
+ // ─── CORS — required for browser-based MCP clients ──────────────
156
+ for (const path of MCP_PATHS) {
157
+ app.use(path, (req, res, next) => {
158
+ res.setHeader("Access-Control-Allow-Origin", "*");
159
+ res.setHeader("Access-Control-Allow-Methods", "POST, GET, DELETE, OPTIONS");
160
+ res.setHeader("Access-Control-Allow-Headers", "content-type, mcp-session-id, authorization");
161
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
162
+ if (req.method === "OPTIONS") {
163
+ res.status(204).end();
164
+ return;
165
+ }
166
+ next();
167
+ });
168
+ }
169
+
20
170
  // ─── Health check ─────────────────────────────────────────────
21
171
  app.get("/health", (_req, res) => {
22
172
  res.json({ ok: true, service: "ateam-mcp", transport: "http" });
@@ -28,21 +178,27 @@ export function startHttpServer(port = 3100) {
28
178
  });
29
179
 
30
180
  // ─── MCP POST — handle tool calls + initialize ───────────────
31
- app.post("/mcp", async (req, res) => {
181
+ // Mounted at both "/" and "/mcp" for Claude.ai compatibility
182
+ const mcpPost = async (req, res) => {
32
183
  const sessionId = req.headers["mcp-session-id"];
33
184
 
34
185
  try {
35
186
  let transport;
36
187
 
37
188
  if (sessionId && transports[sessionId]) {
38
- // Reuse existing session
189
+ // Reuse existing session — seed credentials if Bearer token present
39
190
  transport = transports[sessionId];
191
+ seedCredentials(req, sessionId);
40
192
  } else if (!sessionId && isInitializeRequest(req.body)) {
41
193
  // New session — generate ID upfront so we can bind it to the server
42
194
  const newSessionId = randomUUID();
43
195
 
196
+ // Seed credentials from OAuth Bearer token before server starts
197
+ seedCredentials(req, newSessionId);
198
+
44
199
  transport = new StreamableHTTPServerTransport({
45
200
  sessionIdGenerator: () => newSessionId,
201
+ enableJsonResponse: true,
46
202
  onsessioninitialized: (sid) => {
47
203
  transports[sid] = transport;
48
204
  },
@@ -80,32 +236,57 @@ export function startHttpServer(port = 3100) {
80
236
  });
81
237
  }
82
238
  }
83
- });
239
+ };
84
240
 
85
241
  // ─── MCP GET — SSE stream for notifications ──────────────────
86
- app.get("/mcp", async (req, res) => {
242
+ const mcpGet = async (req, res) => {
87
243
  const sessionId = req.headers["mcp-session-id"];
88
244
  if (!sessionId || !transports[sessionId]) {
89
- res.status(400).send("Invalid or missing session ID");
245
+ // No session: return health-check JSON (for ChatGPT connector validation)
246
+ res.json({ ok: true, service: "ateam-mcp", transport: "http" });
90
247
  return;
91
248
  }
92
249
  await transports[sessionId].handleRequest(req, res);
93
- });
250
+ };
94
251
 
95
252
  // ─── MCP DELETE — session termination ────────────────────────
96
- app.delete("/mcp", async (req, res) => {
253
+ const mcpDelete = async (req, res) => {
97
254
  const sessionId = req.headers["mcp-session-id"];
98
255
  if (!sessionId || !transports[sessionId]) {
99
256
  res.status(400).send("Invalid or missing session ID");
100
257
  return;
101
258
  }
102
259
  await transports[sessionId].handleRequest(req, res);
260
+ };
261
+
262
+ // Mount MCP handlers at both "/" and "/mcp"
263
+ // "/" (Claude.ai): strict OAuth — requires Bearer token
264
+ // "/mcp" (ChatGPT): optional auth — accepts OAuth OR ateam_auth tool
265
+ for (const path of MCP_PATHS) {
266
+ const auth = path === "/" ? mcpAuthStrict : mcpAuthOptional;
267
+ app.post(path, ...auth, mcpPost);
268
+ app.get(path, ...auth, mcpGet);
269
+ app.delete(path, ...auth, mcpDelete);
270
+ }
271
+
272
+ // ─── Catch-all: log unhandled requests ──────────────────────────
273
+ app.use((req, res, next) => {
274
+ console.log(`[HTTP] UNMATCHED: ${req.method} ${req.originalUrl || req.url}`);
275
+ if (!res.headersSent) res.status(404).json({ error: "Not found" });
276
+ });
277
+
278
+ // ─── Error handler ──────────────────────────────────────────────
279
+ app.use((err, req, res, next) => {
280
+ console.error(`[HTTP] ERROR in ${req.method} ${req.originalUrl}:`, err.message || err);
281
+ if (!res.headersSent) {
282
+ res.status(500).json({ error: "Internal server error" });
283
+ }
103
284
  });
104
285
 
105
286
  // ─── Start ────────────────────────────────────────────────────
106
287
  app.listen(port, "0.0.0.0", () => {
107
288
  console.log(`ateam-mcp HTTP server listening on port ${port}`);
108
- console.log(` MCP endpoint: http://localhost:${port}/mcp`);
289
+ console.log(` MCP endpoint: http://localhost:${port}/mcp (also at /)`);
109
290
  console.log(` Health check: http://localhost:${port}/health`);
110
291
  });
111
292
 
@@ -121,3 +302,27 @@ export function startHttpServer(port = 3100) {
121
302
  process.exit(0);
122
303
  });
123
304
  }
305
+
306
+ /** Returns the most recently issued non-expired token, or null. */
307
+ function getNewestToken() {
308
+ let newest = null;
309
+ for (const [, entry] of recentTokens) {
310
+ if (Date.now() - entry.createdAt > TOKEN_TTL) continue;
311
+ if (!newest || entry.createdAt > newest.createdAt) newest = entry;
312
+ }
313
+ return newest?.token || null;
314
+ }
315
+
316
+ /**
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.
319
+ */
320
+ function seedCredentials(req, sessionId) {
321
+ const token = req.auth?.token;
322
+ if (!token) return;
323
+
324
+ const parsed = parseApiKey(token);
325
+ if (parsed.isValid) {
326
+ setSessionCredentials(sessionId, { tenant: parsed.tenant, apiKey: token });
327
+ }
328
+ }
package/src/index.js CHANGED
@@ -17,7 +17,7 @@ if (httpFlag) {
17
17
  // ─── HTTP transport (for ChatGPT, remote clients) ─────────────
18
18
  const { startHttpServer } = await import("./http.js");
19
19
  const portArg = process.argv[process.argv.indexOf("--http") + 1];
20
- const port = portArg && !portArg.startsWith("-") ? parseInt(portArg, 10) : 3100;
20
+ const port = portArg && !portArg.startsWith("-") ? parseInt(portArg, 10) : parseInt(process.env.PORT, 10) || 3100;
21
21
  startHttpServer(port);
22
22
  } else {
23
23
  // ─── Stdio transport (for Claude Code, Cursor, Windsurf, VS Code) ──