@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.
- package/README.md +548 -0
- package/access/auth.js +289 -0
- package/access/oauth.js +1059 -0
- package/access/resource-metadata.js +167 -0
- package/access/tiers.js +422 -0
- package/analytics.js +634 -0
- package/api-bridge.js +499 -0
- package/governance/money.js +141 -0
- package/output-formatter.js +589 -0
- package/package.json +68 -0
- package/resources.js +246 -0
- package/security.js +690 -0
- package/server.js +2139 -0
- package/server.json +55 -0
- package/skills/backlog-triage/SKILL.md +115 -0
- package/skills/board-review/SKILL.md +96 -0
- package/skills/incident-to-card/SKILL.md +126 -0
- package/skills/log-outreach.md +62 -0
- package/skills/ship-the-card/SKILL.md +155 -0
- package/tool-annotations.js +269 -0
- package/tools/billing.js +149 -0
- package/tools/email-tools.js +652 -0
- package/tools/enterprise-tools.js +651 -0
- package/tools/free-search.js +160 -0
- package/tools/memory.js +440 -0
- package/tools/morning-brief.js +551 -0
- package/tools/paper-tools.js +563 -0
- package/tools/scheduling.js +322 -0
- package/tools/work-board-tools.js +758 -0
- package/toolsets/communications.js +276 -0
- package/toolsets/crm.js +495 -0
- package/toolsets/extensibility.js +1131 -0
- package/toolsets/infrastructure.js +757 -0
- package/toolsets/intelligence.js +232 -0
- package/toolsets/knowledge.js +154 -0
- package/toolsets/matrix.js +217 -0
- package/toolsets/outreach.js +432 -0
- package/toolsets/prospecting.js +314 -0
- package/toolsets/revenue/always-loaded.js +341 -0
- package/toolsets/revenue/sloan-tools.js +81 -0
- 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
|
+
};
|