@adrata/adrata-mcp 1.0.0

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.
Files changed (41) hide show
  1. package/README.md +548 -0
  2. package/access/auth.js +289 -0
  3. package/access/oauth.js +1059 -0
  4. package/access/resource-metadata.js +167 -0
  5. package/access/tiers.js +422 -0
  6. package/analytics.js +634 -0
  7. package/api-bridge.js +499 -0
  8. package/governance/money.js +141 -0
  9. package/output-formatter.js +589 -0
  10. package/package.json +68 -0
  11. package/resources.js +246 -0
  12. package/security.js +690 -0
  13. package/server.js +2139 -0
  14. package/server.json +55 -0
  15. package/skills/backlog-triage/SKILL.md +115 -0
  16. package/skills/board-review/SKILL.md +96 -0
  17. package/skills/incident-to-card/SKILL.md +126 -0
  18. package/skills/log-outreach.md +62 -0
  19. package/skills/ship-the-card/SKILL.md +155 -0
  20. package/tool-annotations.js +269 -0
  21. package/tools/billing.js +149 -0
  22. package/tools/email-tools.js +652 -0
  23. package/tools/enterprise-tools.js +651 -0
  24. package/tools/free-search.js +160 -0
  25. package/tools/memory.js +440 -0
  26. package/tools/morning-brief.js +551 -0
  27. package/tools/paper-tools.js +563 -0
  28. package/tools/scheduling.js +322 -0
  29. package/tools/work-board-tools.js +758 -0
  30. package/toolsets/communications.js +276 -0
  31. package/toolsets/crm.js +495 -0
  32. package/toolsets/extensibility.js +1131 -0
  33. package/toolsets/infrastructure.js +757 -0
  34. package/toolsets/intelligence.js +232 -0
  35. package/toolsets/knowledge.js +154 -0
  36. package/toolsets/matrix.js +217 -0
  37. package/toolsets/outreach.js +432 -0
  38. package/toolsets/prospecting.js +314 -0
  39. package/toolsets/revenue/always-loaded.js +341 -0
  40. package/toolsets/revenue/sloan-tools.js +81 -0
  41. package/transport-http.js +505 -0
@@ -0,0 +1,81 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Sloan (AI executive assistant) tools — Wave 3 chat-native management suite.
3
+ //
4
+ // Thin first-class wrappers over the SAME governed AI-CRM tool dispatcher the
5
+ // Adrata chat uses (POST /api/v1/ai-crm-tools/execute), so MCP/CLI users get
6
+ // exact parity with chat: sloan_status (read), sloan_handoff (write),
7
+ // configure_sloan (write; action 'check' is a read-only prerequisites
8
+ // checklist). Server-side gates apply identically: configure_sloan mutations
9
+ // are admin/manager-only, writes require approved=true + a reason and accept
10
+ // an idempotency key — same two-step confirmation contract as
11
+ // adrata_ai_tool_execute.
12
+ //
13
+ // NOTE: every Sloan tool (including approve_sloan_draft / reject_sloan_draft,
14
+ // which have no dedicated wrapper here) is ALSO callable through the generic
15
+ // `adrata_ai_tool_execute` passthrough — these wrappers exist for
16
+ // discoverability and typed schemas, not because new plumbing was needed.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Register the Sloan EA tools.
21
+ *
22
+ * @param {object} server - McpServer instance
23
+ * @param {object} z - zod
24
+ * @param {Function} executeAiCrmTool - the shared dispatcher-execute helper
25
+ * @param {Function} okFn - the ok() response helper
26
+ */
27
+ export function registerSloanTools(server, z, executeAiCrmTool, okFn) {
28
+ server.tool(
29
+ 'sloan_status',
30
+ "What Sloan (the AI executive assistant) is doing: follow-up objective counts by state, work due soon, drafts awaiting approval, and recent events. Scope 'me' (default) shows your objectives; 'workspace' (managers only) shows every seller. Filter to one prospect with personId/companyId/recipientEmail.",
31
+ {
32
+ scope: z.enum(['me', 'workspace']).optional().describe("'me' (default) or 'workspace' (manager/admin only)"),
33
+ userId: z.string().optional().describe("View one seller's Sloan activity (manager/admin only)"),
34
+ personId: z.string().optional().describe('Filter to objectives for this CRM person'),
35
+ companyId: z.string().optional().describe('Filter to objectives for this CRM company'),
36
+ recipientEmail: z.string().optional().describe('Filter to objectives whose recipient email contains this text'),
37
+ rangeHours: z.number().optional().describe('Window for due-soon/recent events (default 24, max 2160)'),
38
+ },
39
+ async (args) => okFn(await executeAiCrmTool('sloan_status', args)),
40
+ );
41
+
42
+ server.tool(
43
+ 'sloan_handoff',
44
+ "Hand a prospect to Sloan (the AI executive assistant): creates a follow-up objective so Sloan pursues the meeting goal in the seller's autonomy mode (DraftOnly by default — every email waits for approval). GOVERNED WRITE: pass approved=true with a reason (and ideally an idempotencyKey); without approval the dispatcher returns the confirmation contract instead of writing.",
45
+ {
46
+ personId: z.string().optional().describe('CRM person id of the prospect (preferred)'),
47
+ email: z.string().optional().describe('Prospect email when no personId is known'),
48
+ name: z.string().optional().describe('Prospect full name (resolved against workspace people)'),
49
+ meetingGoal: z.string().optional().describe("What Sloan should book, e.g. 'Book a 30-min security review'"),
50
+ timezone: z.string().optional().describe('Prospect IANA timezone, if known'),
51
+ autonomyMode: z
52
+ .enum(['draft_only', 'approve_first_send', 'approve_all_replies', 'auto_high_confidence', 'full_auto'])
53
+ .optional()
54
+ .describe("Optional; may only LOWER autonomy versus the seller's configured mode"),
55
+ idempotencyKey: z.string().optional().describe('Idempotency key for the write'),
56
+ approved: z.boolean().optional().describe('Explicit user approval for this write'),
57
+ reason: z.string().optional().describe('Audit reason for this write'),
58
+ },
59
+ async ({ approved, reason, ...args }) =>
60
+ okFn(await executeAiCrmTool('sloan_handoff', args, { approved, reason })),
61
+ );
62
+
63
+ server.tool(
64
+ 'configure_sloan',
65
+ "Configure Sloan for the workspace. action 'check' (default) is READ-ONLY: a prerequisites checklist (workspace flag, server kill-switch, connected Sloan mailbox, operational scheduling provider). Actions 'enable'/'disable' toggle the sloan.ea_loop flag and 'set_autonomy' sets a seller's default autonomy mode — admin/manager-gated server-side; pass approved=true with a reason for mutations.",
66
+ {
67
+ action: z.enum(['check', 'enable', 'disable', 'set_autonomy']).optional().describe("Default 'check' (read-only)"),
68
+ autonomyMode: z
69
+ .enum(['draft_only', 'approve_first_send', 'approve_all_replies', 'auto_high_confidence', 'full_auto'])
70
+ .optional()
71
+ .describe("Required for 'set_autonomy'"),
72
+ userId: z.string().optional().describe("For 'set_autonomy': target seller (defaults to the caller)"),
73
+ autoInherit: z.boolean().optional().describe('Optionally set sloan.auto_inherit alongside enable/disable (ignored if absent in this build)'),
74
+ idempotencyKey: z.string().optional().describe('Idempotency key for mutations'),
75
+ approved: z.boolean().optional().describe('Explicit user approval (mutations only)'),
76
+ reason: z.string().optional().describe('Audit reason (mutations only)'),
77
+ },
78
+ async ({ approved, reason, ...args }) =>
79
+ okFn(await executeAiCrmTool('configure_sloan', args, { approved, reason })),
80
+ );
81
+ }
@@ -0,0 +1,505 @@
1
+ /**
2
+ * Streamable HTTP transport for the Adrata MCP Server.
3
+ *
4
+ * Uses the @modelcontextprotocol/sdk built-in StreamableHTTPServerTransport
5
+ * which supports both direct JSON-RPC responses and SSE streaming via a
6
+ * single POST /mcp endpoint (Streamable HTTP spec).
7
+ *
8
+ * Environment:
9
+ * ADRATA_MCP_PORT - HTTP port (default 3100)
10
+ * ADRATA_MCP_CORS_ORIGINS - Comma-separated allowed origins (default '*')
11
+ * ADRATA_MCP_REQUIRE_AUTH - Require Bearer auth for /mcp (default true)
12
+ *
13
+ * Auth:
14
+ * Authorization header is validated through the same tier system used by
15
+ * the stdio transport. Each connection gets an isolated session via the
16
+ * SDK's built-in session management.
17
+ */
18
+
19
+ import http from 'node:http';
20
+ import crypto from 'node:crypto';
21
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
22
+ import { authenticate } from './access/auth.js';
23
+ import { getCorsHeaders, isOriginAllowed, checkTransportSecurity, isIpAllowed, checkRateLimit, rateLimitResponse as buildRateLimitResponse } from './security.js';
24
+ import { protectedResourceMetadata, validateTokenAudience } from './access/resource-metadata.js';
25
+
26
+ const PROTECTED_RESOURCE_METADATA_PATH = '/.well-known/oauth-protected-resource';
27
+
28
+ const PORT = parseInt(process.env.ADRATA_MCP_PORT || '3100', 10);
29
+ const CORS_ORIGINS = process.env.ADRATA_MCP_CORS_ORIGINS || '*';
30
+ const REQUIRE_HTTP_AUTH = process.env.ADRATA_MCP_REQUIRE_AUTH !== 'false';
31
+ const TRUST_PROXY = process.env.ADRATA_MCP_TRUST_PROXY === 'true';
32
+ const MAX_BODY_BYTES = parsePositiveInt(process.env.ADRATA_MCP_MAX_BODY_BYTES, 1024 * 1024);
33
+ const MAX_SESSIONS = parsePositiveInt(process.env.ADRATA_MCP_MAX_SESSIONS, 1000);
34
+ const SESSION_IDLE_MS = parsePositiveInt(process.env.ADRATA_MCP_SESSION_IDLE_MS, 30 * 60 * 1000);
35
+ const TOKEN_VALIDATION_TTL_MS = parsePositiveInt(process.env.ADRATA_MCP_TOKEN_VALIDATION_TTL_MS, 60_000);
36
+ const TOKEN_VALIDATION_TIMEOUT_MS = parsePositiveInt(process.env.ADRATA_MCP_TOKEN_VALIDATION_TIMEOUT_MS, 5_000);
37
+ // Session bindings only need to remain stable for this process lifetime. A
38
+ // keyed fingerprint avoids exposing a reusable verifier for bearer tokens.
39
+ const AUTHORIZATION_FINGERPRINT_KEY = crypto.randomBytes(32);
40
+
41
+ function parsePositiveInt(value, fallback) {
42
+ const parsed = Number.parseInt(value || '', 10);
43
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Per-session transport management
48
+ // ---------------------------------------------------------------------------
49
+ // Each client session gets its own StreamableHTTPServerTransport instance,
50
+ // keyed by the session ID returned in the Mcp-Session-Id header. The MCP
51
+ // server is connected to each transport independently so sessions are fully
52
+ // isolated.
53
+ //
54
+ // For initialization requests (no session ID), we create a new transport,
55
+ // connect it to the server, and let the SDK assign a session ID.
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /** @type {Map<string, { transport: StreamableHTTPServerTransport, principal: string, lastSeenAt: number }>} */
59
+ const sessions = new Map();
60
+ const validatedPrincipals = new Map();
61
+
62
+ function principalFingerprint(req) {
63
+ const authorization = req.headers['authorization'] || '';
64
+ return crypto
65
+ .createHmac('sha256', AUTHORIZATION_FINGERPRINT_KEY)
66
+ .update(authorization)
67
+ .digest('hex');
68
+ }
69
+
70
+ async function validateHostedBearer(auth, token, principal) {
71
+ const cachedUntil = validatedPrincipals.get(principal) || 0;
72
+ if (cachedUntil > Date.now()) return true;
73
+
74
+ const apiBase = auth.apiUrl || process.env.ADRATA_API_URL || 'https://api.adrata.com';
75
+ const validationPath = process.env.ADRATA_MCP_TOKEN_VALIDATION_PATH || '/api/v1/capabilities/workspace';
76
+ const validationUrl = new URL(validationPath, apiBase);
77
+
78
+ try {
79
+ const response = await fetch(validationUrl, {
80
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
81
+ signal: AbortSignal.timeout(TOKEN_VALIDATION_TIMEOUT_MS),
82
+ });
83
+ if (!response.ok) return false;
84
+ if (validatedPrincipals.size >= MAX_SESSIONS) {
85
+ const oldest = validatedPrincipals.keys().next().value;
86
+ if (oldest) validatedPrincipals.delete(oldest);
87
+ }
88
+ validatedPrincipals.set(principal, Date.now() + TOKEN_VALIDATION_TTL_MS);
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ function forwardedHeader(req, name) {
96
+ return TRUST_PROXY ? req.headers[name] : undefined;
97
+ }
98
+
99
+ function requestProtocol(req) {
100
+ const forwarded = forwardedHeader(req, 'x-forwarded-proto');
101
+ return (typeof forwarded === 'string' ? forwarded.split(',').at(-1)?.trim() : null)
102
+ || (req.socket.encrypted ? 'https' : 'http');
103
+ }
104
+
105
+ function clientAddress(req) {
106
+ const forwarded = forwardedHeader(req, 'x-forwarded-for');
107
+ // A trusted ALB appends the address it observed. Use the right-most value so
108
+ // an attacker-supplied leading X-Forwarded-For value cannot bypass policy.
109
+ return (typeof forwarded === 'string' ? forwarded.split(',').at(-1)?.trim() : null)
110
+ || req.socket.remoteAddress
111
+ || 'unknown';
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Auth from Authorization header
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /**
119
+ * Extract auth context from request headers. Falls back to env-based auth
120
+ * if no Authorization header is present (preserves existing behavior).
121
+ */
122
+ function authenticateRequest(req) {
123
+ const authHeader = req.headers['authorization'] || '';
124
+
125
+ if (authHeader.startsWith('Bearer ')) {
126
+ const token = authHeader.slice(7);
127
+ // Treat a bearer token in the header the same as ADRATA_OAUTH_TOKEN
128
+ // (enterprise tier) for the duration of this request.
129
+ const saved = process.env.ADRATA_OAUTH_TOKEN;
130
+ process.env.ADRATA_OAUTH_TOKEN = token;
131
+ const auth = authenticate();
132
+ // Restore env to avoid leaking across requests
133
+ if (saved !== undefined) {
134
+ process.env.ADRATA_OAUTH_TOKEN = saved;
135
+ } else {
136
+ delete process.env.ADRATA_OAUTH_TOKEN;
137
+ }
138
+ return auth;
139
+ }
140
+
141
+ if (authHeader.startsWith('ApiKey ')) {
142
+ const key = authHeader.slice(7);
143
+ const saved = process.env.ADRATA_API_KEY;
144
+ process.env.ADRATA_API_KEY = key;
145
+ const auth = authenticate();
146
+ if (saved !== undefined) {
147
+ process.env.ADRATA_API_KEY = saved;
148
+ } else {
149
+ delete process.env.ADRATA_API_KEY;
150
+ }
151
+ return auth;
152
+ }
153
+
154
+ // Fall back to environment-based auth
155
+ return authenticate();
156
+ }
157
+
158
+ function hasHostedBearerAuth(req) {
159
+ const authHeader = req.headers['authorization'] || '';
160
+ return typeof authHeader === 'string' && authHeader.startsWith('Bearer ');
161
+ }
162
+
163
+ function bearerToken(req) {
164
+ const authHeader = req.headers['authorization'] || '';
165
+ return authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
166
+ }
167
+
168
+ /** The absolute URL of THIS resource server's RFC 9728 metadata document. */
169
+ function resourceMetadataUrl(req) {
170
+ const proto = requestProtocol(req);
171
+ const host = req.headers.host || `localhost:${PORT}`;
172
+ return `${proto}://${host}${PROTECTED_RESOURCE_METADATA_PATH}`;
173
+ }
174
+
175
+ function writeJson(res, statusCode, body, headers = {}) {
176
+ res.writeHead(statusCode, { 'Content-Type': 'application/json', ...headers });
177
+ res.end(JSON.stringify(body));
178
+ }
179
+
180
+ /**
181
+ * RFC 9728 §5.1 challenge: a 401 that points the client at this resource's
182
+ * protected-resource-metadata so it can discover the authorization server.
183
+ * `error` is an RFC 6750 code — `invalid_token` when a token was rejected
184
+ * (e.g. wrong audience), otherwise a bare challenge.
185
+ */
186
+ function writeUnauthorized(res, req, { error, description } = {}) {
187
+ const metadataUrl = req ? resourceMetadataUrl(req) : `https://api.adrata.com${PROTECTED_RESOURCE_METADATA_PATH}`;
188
+ let challenge = `Bearer realm="adrata-mcp", resource_metadata="${metadataUrl}"`;
189
+ if (error) challenge += `, error="${error}"`;
190
+ if (description) challenge += `, error_description="${description}"`;
191
+ writeJson(
192
+ res,
193
+ 401,
194
+ {
195
+ error: error || 'unauthorized',
196
+ message: description || 'Bearer token authentication is required for hosted Adrata MCP.',
197
+ },
198
+ { 'WWW-Authenticate': challenge },
199
+ );
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // CORS helpers
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function setCorsHeaders(res, req) {
207
+ const origin = req.headers['origin'] || '';
208
+
209
+ // Apply security headers (HSTS, X-Content-Type-Options, etc.)
210
+ const securityHeaders = getCorsHeaders(origin);
211
+ for (const [key, value] of Object.entries(securityHeaders)) {
212
+ res.setHeader(key, value);
213
+ }
214
+
215
+ // Standard CORS handling (falls back to env config if security module allows)
216
+ if (CORS_ORIGINS === '*' && isOriginAllowed(origin)) {
217
+ res.setHeader('Access-Control-Allow-Origin', origin || '*');
218
+ } else if (CORS_ORIGINS !== '*') {
219
+ const allowed = CORS_ORIGINS.split(',').map(o => o.trim());
220
+ if (allowed.includes(origin)) {
221
+ res.setHeader('Access-Control-Allow-Origin', origin);
222
+ }
223
+ }
224
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
225
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id, X-API-Key');
226
+ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
227
+ res.setHeader('Access-Control-Max-Age', '86400');
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Request body reader
232
+ // ---------------------------------------------------------------------------
233
+
234
+ function readBody(req, maxBytes = MAX_BODY_BYTES) {
235
+ return new Promise((resolve, reject) => {
236
+ const chunks = [];
237
+ let size = 0;
238
+ let exceeded = false;
239
+ req.on('data', (chunk) => {
240
+ if (exceeded) return;
241
+ size += chunk.length;
242
+ if (size > maxBytes) {
243
+ exceeded = true;
244
+ const error = new Error('Request body too large');
245
+ error.code = 'BODY_TOO_LARGE';
246
+ reject(error);
247
+ return;
248
+ }
249
+ chunks.push(chunk);
250
+ });
251
+ req.on('end', () => {
252
+ if (!exceeded) resolve(Buffer.concat(chunks).toString('utf-8'));
253
+ });
254
+ req.on('error', reject);
255
+ });
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Start HTTP transport
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /**
263
+ * Start the Streamable HTTP server and return a cleanup function.
264
+ *
265
+ * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} mcpServer
266
+ * The shared MCP server instance (same one used by stdio).
267
+ * @returns {Promise<http.Server>}
268
+ */
269
+ export async function startHttpTransport(mcpServer, options = {}) {
270
+ const runWithAuthContext = options.runWithAuthContext || ((auth, fn) => fn());
271
+
272
+ const httpServer = http.createServer(async (req, res) => {
273
+ const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
274
+ const pathname = url.pathname;
275
+
276
+ // CORS preflight
277
+ if (req.method === 'OPTIONS') {
278
+ setCorsHeaders(res, req);
279
+ res.writeHead(204);
280
+ res.end();
281
+ return;
282
+ }
283
+
284
+ if (pathname === '/health') {
285
+ setCorsHeaders(res, req);
286
+ writeJson(res, 200, { status: 'ok' });
287
+ return;
288
+ }
289
+
290
+ // RFC 9728 OAuth 2.0 Protected Resource Metadata — public, unauthenticated.
291
+ // Served at the base path and the MCP-scoped suffix so clients that append
292
+ // the resource path segment (per the 2025 MCP spec) also resolve it.
293
+ if (pathname === PROTECTED_RESOURCE_METADATA_PATH
294
+ || pathname === `${PROTECTED_RESOURCE_METADATA_PATH}/mcp`) {
295
+ setCorsHeaders(res, req);
296
+ if (req.method !== 'GET') {
297
+ writeJson(res, 405, { error: 'Method not allowed. Use GET.' });
298
+ return;
299
+ }
300
+ writeJson(res, 200, protectedResourceMetadata(resourceMetadataUrl(req)));
301
+ return;
302
+ }
303
+
304
+ // Transport security: reject HTTP in production (require TLS)
305
+ const proto = requestProtocol(req);
306
+ const transportCheck = checkTransportSecurity(`${proto}://${req.headers.host}`);
307
+ if (!transportCheck.secure) {
308
+ writeJson(res, 403, { error: transportCheck.warning });
309
+ return;
310
+ }
311
+
312
+ // IP allowlisting (enterprise feature)
313
+ const clientIp = clientAddress(req);
314
+ if (!isIpAllowed(clientIp)) {
315
+ writeJson(res, 403, { error: 'IP address not in allowlist.' });
316
+ return;
317
+ }
318
+
319
+ // Only handle /mcp
320
+ if (pathname !== '/mcp') {
321
+ setCorsHeaders(res, req);
322
+ writeJson(res, 404, { error: 'Not found. Use POST /mcp for JSON-RPC calls.' });
323
+ return;
324
+ }
325
+
326
+ setCorsHeaders(res, req);
327
+
328
+ // Validate auth
329
+ if (REQUIRE_HTTP_AUTH && !hasHostedBearerAuth(req)) {
330
+ writeUnauthorized(res, req);
331
+ return;
332
+ }
333
+
334
+ // RFC 8707 audience binding / no-token-passthrough: a bearer token minted
335
+ // for a different audience (another resource or client) MUST NOT be
336
+ // accepted here. This is enforced before we touch the tier system so a
337
+ // cross-audience token can never reach a tool or the upstream API.
338
+ const presentedToken = bearerToken(req);
339
+ if (presentedToken) {
340
+ const audCheck = validateTokenAudience(presentedToken);
341
+ if (!audCheck.valid) {
342
+ writeUnauthorized(res, req, {
343
+ error: 'invalid_token',
344
+ description: audCheck.reason === 'audience_mismatch'
345
+ ? 'Access token audience does not match this MCP resource.'
346
+ : 'Access token is not valid for this MCP resource.',
347
+ });
348
+ return;
349
+ }
350
+ }
351
+
352
+ const auth = authenticateRequest(req);
353
+ if (REQUIRE_HTTP_AUTH && !auth.authenticated) {
354
+ writeUnauthorized(res, req);
355
+ return;
356
+ }
357
+
358
+ const principal = principalFingerprint(req);
359
+ auth.securityPrincipal = principal;
360
+ if (presentedToken && !(await validateHostedBearer(auth, presentedToken, principal))) {
361
+ writeUnauthorized(res, req, {
362
+ error: 'invalid_token',
363
+ description: 'Access token could not be validated by the Adrata authorization boundary.',
364
+ });
365
+ return;
366
+ }
367
+
368
+ // Attach auth info for the SDK's authInfo passthrough
369
+ req.auth = { tier: auth.tier, authenticated: auth.authenticated };
370
+
371
+ const sessionId = req.headers['mcp-session-id'];
372
+
373
+ if (sessionId && sessions.has(sessionId)) {
374
+ const session = sessions.get(sessionId);
375
+ if (session.principal !== principal) {
376
+ writeJson(res, 403, { error: 'MCP session belongs to a different authenticated principal.' });
377
+ return;
378
+ }
379
+ session.lastSeenAt = Date.now();
380
+ }
381
+
382
+ if (req.method === 'POST') {
383
+ // Read and parse body
384
+ let body;
385
+ try {
386
+ const raw = await readBody(req);
387
+ body = JSON.parse(raw);
388
+ } catch (error) {
389
+ if (error?.code === 'BODY_TOO_LARGE') {
390
+ if (!res.headersSent) writeJson(res, 413, { error: `Request body exceeds ${MAX_BODY_BYTES} bytes.` });
391
+ return;
392
+ }
393
+ writeJson(res, 400, { jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null });
394
+ return;
395
+ }
396
+
397
+ // Check if this is an initialization request (method: 'initialize')
398
+ const isInit = Array.isArray(body)
399
+ ? body.some(msg => msg.method === 'initialize')
400
+ : body.method === 'initialize';
401
+
402
+ let transport;
403
+
404
+ if (isInit) {
405
+ if (sessions.size >= MAX_SESSIONS) {
406
+ writeJson(res, 503, { error: 'MCP session capacity reached. Retry later.' });
407
+ return;
408
+ }
409
+ // Create a new transport for this session
410
+ transport = new StreamableHTTPServerTransport({
411
+ sessionIdGenerator: () => crypto.randomUUID(),
412
+ });
413
+
414
+ // When transport closes, clean up the session
415
+ transport.onclose = () => {
416
+ const sid = transport.sessionId;
417
+ if (sid) sessions.delete(sid);
418
+ };
419
+
420
+ // Connect the MCP server to this transport
421
+ await runWithAuthContext(auth, () => mcpServer.connect(transport));
422
+
423
+ // Handle the request (the SDK will set the session ID during init)
424
+ await runWithAuthContext(auth, () => transport.handleRequest(req, res, body));
425
+
426
+ // Store the session after initialization
427
+ const sid = transport.sessionId;
428
+ if (sid) {
429
+ sessions.set(sid, { transport, principal, lastSeenAt: Date.now() });
430
+ }
431
+ } else if (sessionId && sessions.has(sessionId)) {
432
+ // Existing session
433
+ transport = sessions.get(sessionId).transport;
434
+ await runWithAuthContext(auth, () => transport.handleRequest(req, res, body));
435
+ } else {
436
+ // No session ID or unknown session for a non-init request
437
+ writeJson(res, 400, {
438
+ jsonrpc: '2.0',
439
+ error: { code: -32600, message: 'Bad Request: missing or invalid session. Send an initialize request first.' },
440
+ id: null,
441
+ });
442
+ }
443
+ } else if (req.method === 'GET') {
444
+ // GET /mcp opens a standalone SSE stream for server-initiated notifications
445
+ if (sessionId && sessions.has(sessionId)) {
446
+ const transport = sessions.get(sessionId).transport;
447
+ await runWithAuthContext(auth, () => transport.handleRequest(req, res));
448
+ } else {
449
+ writeJson(res, 400, { error: 'Missing or invalid Mcp-Session-Id header.' });
450
+ }
451
+ } else if (req.method === 'DELETE') {
452
+ // DELETE /mcp terminates a session
453
+ if (sessionId && sessions.has(sessionId)) {
454
+ const transport = sessions.get(sessionId).transport;
455
+ await transport.close();
456
+ sessions.delete(sessionId);
457
+ res.writeHead(204);
458
+ res.end();
459
+ } else {
460
+ writeJson(res, 404, { error: 'Session not found.' });
461
+ }
462
+ } else {
463
+ writeJson(res, 405, { error: 'Method not allowed.' });
464
+ }
465
+ });
466
+
467
+ const sessionCleanup = setInterval(() => {
468
+ const cutoff = Date.now() - SESSION_IDLE_MS;
469
+ for (const [sessionId, session] of sessions) {
470
+ if (session.lastSeenAt < cutoff) {
471
+ sessions.delete(sessionId);
472
+ session.transport.close().catch(() => {});
473
+ }
474
+ }
475
+ }, Math.min(SESSION_IDLE_MS, 60_000));
476
+ sessionCleanup.unref?.();
477
+
478
+ httpServer.on('close', () => {
479
+ clearInterval(sessionCleanup);
480
+ for (const session of sessions.values()) session.transport.close().catch(() => {});
481
+ sessions.clear();
482
+ validatedPrincipals.clear();
483
+ });
484
+
485
+ httpServer.requestTimeout = 30_000;
486
+ httpServer.headersTimeout = 15_000;
487
+ httpServer.keepAliveTimeout = 5_000;
488
+
489
+ return new Promise((resolve) => {
490
+ httpServer.listen(PORT, () => {
491
+ console.error(`Adrata MCP HTTP transport listening on http://localhost:${PORT}/mcp`);
492
+ console.error(`CORS origins: ${CORS_ORIGINS}`);
493
+ console.error(`Active sessions: ${sessions.size}`);
494
+ resolve(httpServer);
495
+ });
496
+ });
497
+ }
498
+
499
+ export const _test = {
500
+ clientAddress,
501
+ requestProtocol,
502
+ principalFingerprint,
503
+ readBody,
504
+ validateHostedBearer,
505
+ };