@ateam-ai/mcp 0.1.11 → 0.1.13
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 +1 -1
- package/src/api.js +60 -17
- package/src/http.js +214 -9
- package/src/index.js +1 -1
- package/src/oauth.js +379 -0
- package/src/server.js +1 -1
- package/src/stub.js +374 -0
- package/src/tools.js +305 -5
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -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>
|
|
@@ -35,6 +41,7 @@ export function parseApiKey(key) {
|
|
|
35
41
|
/**
|
|
36
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
|
-
|
|
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
|
-
*
|
|
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,6 +127,7 @@ function headers(sessionId) {
|
|
|
91
127
|
*/
|
|
92
128
|
function formatError(method, path, status, body) {
|
|
93
129
|
const hints = {
|
|
130
|
+
400: "Bad request — see the error details above for what to fix.",
|
|
94
131
|
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
132
|
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
133
|
404: "Resource not found. Check the solution_id or skill_id you're using. Use ateam_list_solutions to see available solutions.",
|
|
@@ -103,7 +140,7 @@ function formatError(method, path, status, body) {
|
|
|
103
140
|
};
|
|
104
141
|
|
|
105
142
|
const hint = hints[status] || "";
|
|
106
|
-
const detail = typeof body === "string" && body.length > 0 && body.length <
|
|
143
|
+
const detail = typeof body === "string" && body.length > 0 && body.length < 2000 ? body : "";
|
|
107
144
|
|
|
108
145
|
let msg = `A-Team API error: ${method} ${path} returned ${status}`;
|
|
109
146
|
if (detail) msg += ` — ${detail}`;
|
|
@@ -114,22 +151,28 @@ function formatError(method, path, status, body) {
|
|
|
114
151
|
|
|
115
152
|
/**
|
|
116
153
|
* Core fetch wrapper with timeout and error formatting.
|
|
154
|
+
* @param {string} method
|
|
155
|
+
* @param {string} path
|
|
156
|
+
* @param {*} body
|
|
157
|
+
* @param {string} sessionId
|
|
158
|
+
* @param {{ timeoutMs?: number }} [opts]
|
|
117
159
|
*/
|
|
118
|
-
async function request(method, path, body, sessionId) {
|
|
160
|
+
async function request(method, path, body, sessionId, opts = {}) {
|
|
161
|
+
const timeoutMs = opts.timeoutMs || REQUEST_TIMEOUT_MS;
|
|
119
162
|
const controller = new AbortController();
|
|
120
|
-
const timeout = setTimeout(() => controller.abort(),
|
|
163
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
121
164
|
|
|
122
165
|
try {
|
|
123
|
-
const
|
|
166
|
+
const fetchOpts = {
|
|
124
167
|
method,
|
|
125
168
|
headers: headers(sessionId),
|
|
126
169
|
signal: controller.signal,
|
|
127
170
|
};
|
|
128
171
|
if (body !== undefined) {
|
|
129
|
-
|
|
172
|
+
fetchOpts.body = JSON.stringify(body);
|
|
130
173
|
}
|
|
131
174
|
|
|
132
|
-
const res = await fetch(`${BASE_URL}${path}`,
|
|
175
|
+
const res = await fetch(`${BASE_URL}${path}`, fetchOpts);
|
|
133
176
|
|
|
134
177
|
if (!res.ok) {
|
|
135
178
|
const text = await res.text().catch(() => "");
|
|
@@ -140,7 +183,7 @@ async function request(method, path, body, sessionId) {
|
|
|
140
183
|
} catch (err) {
|
|
141
184
|
if (err.name === "AbortError") {
|
|
142
185
|
throw new Error(
|
|
143
|
-
`A-Team API timeout: ${method} ${path} did not respond within ${
|
|
186
|
+
`A-Team API timeout: ${method} ${path} did not respond within ${timeoutMs / 1000}s.\n` +
|
|
144
187
|
`Hint: The A-Team API at ${BASE_URL} may be down. Check https://api.ateam-ai.com/health`
|
|
145
188
|
);
|
|
146
189
|
}
|
|
@@ -162,18 +205,18 @@ async function request(method, path, body, sessionId) {
|
|
|
162
205
|
}
|
|
163
206
|
}
|
|
164
207
|
|
|
165
|
-
export async function get(path, sessionId) {
|
|
166
|
-
return request("GET", path, undefined, sessionId);
|
|
208
|
+
export async function get(path, sessionId, opts) {
|
|
209
|
+
return request("GET", path, undefined, sessionId, opts);
|
|
167
210
|
}
|
|
168
211
|
|
|
169
|
-
export async function post(path, body, sessionId) {
|
|
170
|
-
return request("POST", path, body, sessionId);
|
|
212
|
+
export async function post(path, body, sessionId, opts) {
|
|
213
|
+
return request("POST", path, body, sessionId, opts);
|
|
171
214
|
}
|
|
172
215
|
|
|
173
|
-
export async function patch(path, body, sessionId) {
|
|
174
|
-
return request("PATCH", path, body, sessionId);
|
|
216
|
+
export async function patch(path, body, sessionId, opts) {
|
|
217
|
+
return request("PATCH", path, body, sessionId, opts);
|
|
175
218
|
}
|
|
176
219
|
|
|
177
|
-
export async function del(path, sessionId) {
|
|
178
|
-
return request("DELETE", path, undefined, sessionId);
|
|
220
|
+
export async function del(path, sessionId, opts) {
|
|
221
|
+
return request("DELETE", path, undefined, sessionId, opts);
|
|
179
222
|
}
|
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
|
-
|
|
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
|
-
|
|
242
|
+
const mcpGet = async (req, res) => {
|
|
87
243
|
const sessionId = req.headers["mcp-session-id"];
|
|
88
244
|
if (!sessionId || !transports[sessionId]) {
|
|
89
|
-
|
|
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
|
-
|
|
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) ──
|