@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
package/security.js ADDED
@@ -0,0 +1,690 @@
1
+ /**
2
+ * Enterprise-grade security hardening for Adrata MCP Server.
3
+ *
4
+ * Provides:
5
+ * 1. Rate limiting (sliding window, per-tier)
6
+ * 2. Input validation (Zod-based sanitization)
7
+ * 3. Audit logging (enterprise tier, every non-read tool + mass-egress reads)
8
+ * 4. Token encryption at rest (AES-256-GCM)
9
+ * 5. CORS restrictions (production mode)
10
+ * 6. Transport security helpers
11
+ */
12
+
13
+ import { z } from 'zod';
14
+ import crypto from 'node:crypto';
15
+ import os from 'node:os';
16
+ import { buildToolAnnotations } from './tool-annotations.js';
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Configuration
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const TELEMETRY_ENABLED = process.env.ADRATA_TELEMETRY !== 'off';
23
+ const IS_PRODUCTION = process.env.NODE_ENV === 'production';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // 1. Rate Limiting — Sliding Window
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Sliding window rate limiter using in-memory Map with timestamps.
31
+ * Per-session for stdio transport, per-IP for HTTP/SSE transport.
32
+ */
33
+ class SlidingWindowRateLimiter {
34
+ constructor() {
35
+ // Map<string, number[]> — key -> array of request timestamps (ms)
36
+ this.windows = new Map();
37
+ // Cleanup stale entries every 5 minutes
38
+ this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000);
39
+ if (this.cleanupInterval.unref) this.cleanupInterval.unref();
40
+ }
41
+
42
+ /**
43
+ * Check if a request is allowed under rate limits.
44
+ * @param {string} key — session ID or IP address
45
+ * @param {number} maxRequests — max requests in the window
46
+ * @param {number} windowMs — window size in milliseconds
47
+ * @returns {{ allowed: boolean, remaining: number, retryAfterMs: number|null }}
48
+ */
49
+ check(key, maxRequests, windowMs) {
50
+ const now = Date.now();
51
+ const cutoff = now - windowMs;
52
+
53
+ let timestamps = this.windows.get(key);
54
+ if (!timestamps) {
55
+ timestamps = [];
56
+ this.windows.set(key, timestamps);
57
+ }
58
+
59
+ // Remove expired timestamps
60
+ while (timestamps.length > 0 && timestamps[0] <= cutoff) {
61
+ timestamps.shift();
62
+ }
63
+
64
+ if (timestamps.length >= maxRequests) {
65
+ const oldestInWindow = timestamps[0];
66
+ const retryAfterMs = oldestInWindow + windowMs - now;
67
+ return {
68
+ allowed: false,
69
+ remaining: 0,
70
+ retryAfterMs: Math.max(retryAfterMs, 1000),
71
+ };
72
+ }
73
+
74
+ timestamps.push(now);
75
+ return {
76
+ allowed: true,
77
+ remaining: maxRequests - timestamps.length,
78
+ retryAfterMs: null,
79
+ };
80
+ }
81
+
82
+ cleanup() {
83
+ const now = Date.now();
84
+ // Remove entries with no recent activity (older than 1 hour)
85
+ const oneHourAgo = now - 60 * 60 * 1000;
86
+ for (const [key, timestamps] of this.windows) {
87
+ if (timestamps.length === 0 || timestamps[timestamps.length - 1] < oneHourAgo) {
88
+ this.windows.delete(key);
89
+ }
90
+ }
91
+ }
92
+
93
+ destroy() {
94
+ clearInterval(this.cleanupInterval);
95
+ this.windows.clear();
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Tier-based rate limit configuration.
101
+ * Free tier: 1000 req/hour (abuse protection only)
102
+ * Pro tier: 10000 req/day
103
+ * Enterprise tier: 100000 req/day
104
+ */
105
+ const TIER_RATE_LIMITS = {
106
+ free: { maxRequests: 1000, windowMs: 60 * 60 * 1000 }, // 1000/hour
107
+ pro: { maxRequests: 10000, windowMs: 24 * 60 * 60 * 1000 }, // 10000/day
108
+ enterprise: { maxRequests: 100000, windowMs: 24 * 60 * 60 * 1000 }, // 100000/day
109
+ };
110
+
111
+ const rateLimiter = new SlidingWindowRateLimiter();
112
+
113
+ /**
114
+ * Check rate limit for a request.
115
+ * @param {string} sessionKey — session ID (stdio) or IP (HTTP)
116
+ * @param {string} tier — 'free', 'pro', or 'enterprise'
117
+ * @returns {{ allowed: boolean, remaining: number, retryAfterMs: number|null }}
118
+ */
119
+ export function checkRateLimit(sessionKey, tier) {
120
+ const config = TIER_RATE_LIMITS[tier] || TIER_RATE_LIMITS.free;
121
+ return rateLimiter.check(sessionKey, config.maxRequests, config.windowMs);
122
+ }
123
+
124
+ /**
125
+ * Build a 429 rate-limit response for MCP.
126
+ */
127
+ export function rateLimitResponse(retryAfterMs) {
128
+ const retryAfterSec = Math.ceil(retryAfterMs / 1000);
129
+ return {
130
+ content: [{
131
+ type: 'text',
132
+ text: JSON.stringify({
133
+ error: 'rate_limit_exceeded',
134
+ message: `Rate limit exceeded. Please retry after ${retryAfterSec} seconds.`,
135
+ retryAfterSeconds: retryAfterSec,
136
+ }, null, 2),
137
+ }],
138
+ isError: true,
139
+ };
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // 2. Input Validation
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Common validation schemas for tool parameters.
148
+ * These wrap and tighten the existing Zod schemas.
149
+ */
150
+ export const validators = {
151
+ /** Sanitize a string — strip control characters, limit length. */
152
+ sanitizeString(value, maxLength = 1000) {
153
+ if (typeof value !== 'string') return value;
154
+ // Strip control characters except newline/tab
155
+ // eslint-disable-next-line no-control-regex
156
+ const cleaned = value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
157
+ return cleaned.slice(0, maxLength);
158
+ },
159
+
160
+ /** Validate that a string doesn't contain obvious injection patterns. */
161
+ isSafe(value) {
162
+ if (typeof value !== 'string') return true;
163
+ // Check for common injection patterns.
164
+ // The event-handler check requires HTML tag or attribute-breakout context
165
+ // (`<tag ... onX=` or a quote directly before `onX=`). A bare /on\w+\s*=/
166
+ // rejected legitimate business strings like "phone=555" or "one=1".
167
+ const dangerous = [
168
+ /<script\b/i,
169
+ /javascript:/i,
170
+ /<[^>]*\bon\w+\s*=/i, // <img onerror=, <div onclick = ...
171
+ /["']\s*on\w+\s*=/i, // " onmouseover= (attribute breakout)
172
+ /data:\s*text\/html/i,
173
+ ];
174
+ return !dangerous.some(pattern => pattern.test(value));
175
+ },
176
+
177
+ /** Validate an ID string (UUIDs, prefixed IDs like c-1, p-123). */
178
+ isValidId(value) {
179
+ if (typeof value !== 'string') return false;
180
+ // Allow UUIDs, prefixed IDs (c-1, p-123), and alphanumeric strings
181
+ return /^[a-zA-Z0-9_-]{1,128}$/.test(value);
182
+ },
183
+
184
+ /** Validate an email address format. */
185
+ isValidEmail(value) {
186
+ if (typeof value !== 'string') return false;
187
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && value.length <= 320;
188
+ },
189
+
190
+ /** Validate a URL format. */
191
+ isValidUrl(value) {
192
+ if (typeof value !== 'string') return false;
193
+ try {
194
+ const url = new URL(value);
195
+ return ['http:', 'https:'].includes(url.protocol);
196
+ } catch {
197
+ return false;
198
+ }
199
+ },
200
+ };
201
+
202
+ /**
203
+ * Validate and sanitize all arguments for a tool call.
204
+ * Rejects obviously malicious input, sanitizes strings.
205
+ *
206
+ * @param {string} toolName — name of the tool being called
207
+ * @param {object} args — raw arguments from the client
208
+ * @returns {{ valid: boolean, args: object, error: string|null }}
209
+ */
210
+ export function validateToolInput(toolName, args) {
211
+ if (!args || typeof args !== 'object') {
212
+ return { valid: true, args: args || {}, error: null };
213
+ }
214
+
215
+ const sanitized = {};
216
+ const errors = [];
217
+
218
+ for (const [key, value] of Object.entries(args)) {
219
+ if (typeof value === 'string') {
220
+ // Check for injection attempts
221
+ if (!validators.isSafe(value)) {
222
+ errors.push(`Parameter "${key}" contains potentially unsafe content`);
223
+ continue;
224
+ }
225
+
226
+ // Sanitize string values
227
+ const maxLen = key === 'body' || key === 'message' || key === 'description'
228
+ ? 10000
229
+ : key === 'query' || key === 'search'
230
+ ? 500
231
+ : 1000;
232
+ sanitized[key] = validators.sanitizeString(value, maxLen);
233
+
234
+ // Validate specific field types
235
+ if (key === 'id' || key.endsWith('Id')) {
236
+ if (!validators.isValidId(value)) {
237
+ errors.push(`Parameter "${key}" is not a valid ID format`);
238
+ continue;
239
+ }
240
+ }
241
+
242
+ if (key === 'email' || key === 'personEmail') {
243
+ if (value && !validators.isValidEmail(value)) {
244
+ errors.push(`Parameter "${key}" is not a valid email address`);
245
+ continue;
246
+ }
247
+ }
248
+
249
+ if (key === 'website' || key === 'linkedinUrl' || key === 'twitterUrl') {
250
+ if (value && !validators.isValidUrl(value)) {
251
+ errors.push(`Parameter "${key}" is not a valid URL`);
252
+ continue;
253
+ }
254
+ }
255
+ } else {
256
+ sanitized[key] = value;
257
+ }
258
+ }
259
+
260
+ if (errors.length > 0) {
261
+ return {
262
+ valid: false,
263
+ args: sanitized,
264
+ error: errors.join('; '),
265
+ };
266
+ }
267
+
268
+ return { valid: true, args: sanitized, error: null };
269
+ }
270
+
271
+ /**
272
+ * Build an input validation error response for MCP.
273
+ */
274
+ export function validationErrorResponse(error) {
275
+ return {
276
+ content: [{
277
+ type: 'text',
278
+ text: JSON.stringify({
279
+ error: 'validation_error',
280
+ message: `Input validation failed: ${error}`,
281
+ }, null, 2),
282
+ }],
283
+ isError: true,
284
+ };
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // 3. Audit Logging
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /**
292
+ * In-memory audit log buffer. Entries are flushed to the Rust API
293
+ * periodically or when the buffer reaches a threshold.
294
+ */
295
+ class AuditLogger {
296
+ constructor(apiFn) {
297
+ this.buffer = [];
298
+ this.apiFn = apiFn;
299
+ this.flushThreshold = 50;
300
+ this.maxBufferedEntries = 1000;
301
+ this.flushIntervalMs = 30_000;
302
+ this.flushing = false;
303
+ this.flushTimer = setInterval(() => this.flush(), this.flushIntervalMs);
304
+ if (this.flushTimer.unref) this.flushTimer.unref();
305
+ }
306
+
307
+ /**
308
+ * Log an audit event.
309
+ * @param {{ tool: string, tier: string, action: string, args: object, userId: string|null, sessionKey: string, timestamp: string }} entry
310
+ */
311
+ log(entry) {
312
+ if (!TELEMETRY_ENABLED) return;
313
+
314
+ this.buffer.push({
315
+ tool: entry.tool,
316
+ action: entry.action || 'tool_call',
317
+ tier: entry.tier,
318
+ userId: entry.userId || null,
319
+ sessionKey: entry.sessionKey,
320
+ timestamp: entry.timestamp || new Date().toISOString(),
321
+ // Redact sensitive fields from args
322
+ params: redactSensitiveFields(entry.args),
323
+ });
324
+
325
+ if (this.buffer.length >= this.flushThreshold) {
326
+ this.flush();
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Flush buffered entries to the Rust API.
332
+ * Silently drops entries if the API is unavailable (non-blocking).
333
+ */
334
+ async flush() {
335
+ if (this.buffer.length === 0 || this.flushing) return;
336
+ this.flushing = true;
337
+ const entries = this.buffer.splice(0, this.buffer.length);
338
+
339
+ if (this.apiFn) {
340
+ try {
341
+ await this.apiFn('POST', '/api/v1/mcp/audit', { body: { entries } });
342
+ } catch {
343
+ // Retain a bounded retry buffer. Never retain arbitrary arguments: log()
344
+ // already redacts and truncates them before they enter this buffer.
345
+ this.buffer = [...entries, ...this.buffer].slice(-this.maxBufferedEntries);
346
+ console.error(`[audit] Failed to flush ${entries.length} audit entries; retained for retry`);
347
+ } finally {
348
+ this.flushing = false;
349
+ }
350
+ } else {
351
+ this.flushing = false;
352
+ }
353
+ }
354
+
355
+ destroy() {
356
+ clearInterval(this.flushTimer);
357
+ this.flush();
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Redact fields that may contain secrets or PII from audit logs.
363
+ */
364
+ function redactSensitiveFields(args) {
365
+ if (!args || typeof args !== 'object') return args;
366
+ const redacted = {};
367
+ // Compared against key.toLowerCase(), so every entry MUST be lowercase.
368
+ // 'apiKey'/'creditCard' were stored camelCase and could never match — an
369
+ // apiKey argument landed unredacted in the audit log.
370
+ const sensitiveKeys = new Set([
371
+ 'password', 'token', 'apikey', 'api_key', 'secret', 'authorization',
372
+ 'cookie', 'session', 'creditcard', 'credit_card', 'ssn',
373
+ ]);
374
+
375
+ for (const [key, value] of Object.entries(args)) {
376
+ if (sensitiveKeys.has(key.toLowerCase())) {
377
+ redacted[key] = '[REDACTED]';
378
+ } else if (typeof value === 'string' && value.length > 200) {
379
+ redacted[key] = value.slice(0, 200) + '...[truncated]';
380
+ } else {
381
+ redacted[key] = value;
382
+ }
383
+ }
384
+ return redacted;
385
+ }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // 4. Token Security — AES-256-GCM Encryption at Rest
389
+ // ---------------------------------------------------------------------------
390
+
391
+ /**
392
+ * Encrypt a token for local storage using AES-256-GCM.
393
+ * The encryption key is derived from ADRATA_ENCRYPTION_KEY env var
394
+ * or a machine-specific fallback.
395
+ *
396
+ * @param {string} plaintext — the token to encrypt
397
+ * @returns {string} — base64-encoded encrypted payload (iv:authTag:ciphertext)
398
+ */
399
+ export function encryptToken(plaintext) {
400
+ const key = getEncryptionKey();
401
+ const iv = crypto.randomBytes(12);
402
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
403
+
404
+ let encrypted = cipher.update(plaintext, 'utf8', 'base64');
405
+ encrypted += cipher.final('base64');
406
+ const authTag = cipher.getAuthTag();
407
+
408
+ // Format: iv:authTag:ciphertext (all base64)
409
+ return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
410
+ }
411
+
412
+ /**
413
+ * Decrypt a token from local storage.
414
+ *
415
+ * @param {string} encryptedPayload — output from encryptToken()
416
+ * @returns {string} — the original plaintext token
417
+ */
418
+ export function decryptToken(encryptedPayload) {
419
+ const key = getEncryptionKey();
420
+ const [ivB64, authTagB64, ciphertext] = encryptedPayload.split(':');
421
+
422
+ const iv = Buffer.from(ivB64, 'base64');
423
+ const authTag = Buffer.from(authTagB64, 'base64');
424
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
425
+ decipher.setAuthTag(authTag);
426
+
427
+ let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
428
+ decrypted += decipher.final('utf8');
429
+ return decrypted;
430
+ }
431
+
432
+ /**
433
+ * Derive a 256-bit encryption key.
434
+ * Uses ADRATA_ENCRYPTION_KEY if set, otherwise derives from hostname + username.
435
+ */
436
+ function getEncryptionKey() {
437
+ const source = process.env.ADRATA_ENCRYPTION_KEY
438
+ || `adrata-mcp-${process.env.USER || 'default'}-${os.hostname()}`;
439
+ return crypto.createHash('sha256').update(source).digest();
440
+ }
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // 5. CORS Restrictions
444
+ // ---------------------------------------------------------------------------
445
+
446
+ /**
447
+ * Allowed origins for HTTP/SSE transport in production.
448
+ */
449
+ const ALLOWED_ORIGINS = new Set([
450
+ 'https://app.adrata.com',
451
+ 'https://adrata.com',
452
+ 'https://www.adrata.com',
453
+ 'https://staging.adrata.com',
454
+ ]);
455
+
456
+ /**
457
+ * Check if an origin is allowed for CORS.
458
+ * In development mode, all origins are allowed.
459
+ *
460
+ * @param {string} origin — the Origin header value
461
+ * @returns {boolean}
462
+ */
463
+ export function isOriginAllowed(origin) {
464
+ if (!IS_PRODUCTION) return true;
465
+ if (!origin) return false;
466
+
467
+ // Allow configured origins
468
+ if (ALLOWED_ORIGINS.has(origin)) return true;
469
+
470
+ // Allow custom origins from env
471
+ const customOrigins = process.env.ADRATA_ALLOWED_ORIGINS;
472
+ if (customOrigins) {
473
+ const extras = customOrigins.split(',').map(s => s.trim());
474
+ if (extras.includes(origin)) return true;
475
+ }
476
+
477
+ return false;
478
+ }
479
+
480
+ /**
481
+ * Get CORS headers for an HTTP response.
482
+ *
483
+ * @param {string} origin — the request Origin header
484
+ * @returns {object} — headers to set on the response
485
+ */
486
+ export function getCorsHeaders(origin) {
487
+ const headers = {
488
+ 'X-Content-Type-Options': 'nosniff',
489
+ 'X-Frame-Options': 'DENY',
490
+ 'X-XSS-Protection': '1; mode=block',
491
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
492
+ };
493
+
494
+ if (IS_PRODUCTION) {
495
+ headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains';
496
+ }
497
+
498
+ if (origin && isOriginAllowed(origin)) {
499
+ headers['Access-Control-Allow-Origin'] = origin;
500
+ headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS';
501
+ headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key';
502
+ headers['Access-Control-Max-Age'] = '86400';
503
+ }
504
+
505
+ return headers;
506
+ }
507
+
508
+ // ---------------------------------------------------------------------------
509
+ // 6. Transport Security
510
+ // ---------------------------------------------------------------------------
511
+
512
+ /**
513
+ * Check if the SSE transport is using TLS in production.
514
+ * Returns a warning if HTTP is detected in production mode.
515
+ *
516
+ * @param {string} url — the transport URL
517
+ * @returns {{ secure: boolean, warning: string|null }}
518
+ */
519
+ export function checkTransportSecurity(url) {
520
+ if (!IS_PRODUCTION) {
521
+ return { secure: true, warning: null };
522
+ }
523
+
524
+ if (url && url.startsWith('http://')) {
525
+ return {
526
+ secure: false,
527
+ warning: 'SSE transport requires TLS (HTTPS) in production. HTTP connections are rejected.',
528
+ };
529
+ }
530
+
531
+ return { secure: true, warning: null };
532
+ }
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // 7. IP Allowlisting (Enterprise)
536
+ // ---------------------------------------------------------------------------
537
+
538
+ /**
539
+ * Check if an IP is in the enterprise allowlist.
540
+ * If no allowlist is configured, all IPs are allowed.
541
+ *
542
+ * @param {string} ip — the client IP
543
+ * @returns {boolean}
544
+ */
545
+ export function isIpAllowed(ip) {
546
+ const allowlist = process.env.ADRATA_IP_ALLOWLIST;
547
+ if (!allowlist) return true;
548
+
549
+ const allowed = allowlist.split(',').map(s => s.trim());
550
+ return allowed.includes(ip);
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // 8. Security Middleware — Wraps the tool registry
555
+ // ---------------------------------------------------------------------------
556
+
557
+ /**
558
+ * Create the security layer that wraps the MCP server's tool registry.
559
+ *
560
+ * @param {object} server — the McpServer instance
561
+ * @param {object} auth — the auth context from authenticate()
562
+ * @param {Function} apiFn — the api() helper for making Rust API calls
563
+ * @returns {{ auditLogger: AuditLogger, rateLimiter: SlidingWindowRateLimiter }}
564
+ */
565
+ /**
566
+ * Classify a tool call for the audit log.
567
+ *
568
+ * The composite `manage_*` tools carry their real verb in `args.action`, so a
569
+ * name-prefix guess logs every one of them as "execute" — including deletes.
570
+ * Read the action when there is one; fall back to the name otherwise.
571
+ *
572
+ * @param {string} name
573
+ * @param {object} args
574
+ * @returns {'create'|'read'|'update'|'delete'|'execute'}
575
+ */
576
+ export function auditActionFor(name, args) {
577
+ const action = args && typeof args.action === 'string' ? args.action.toLowerCase() : null;
578
+ if (action) {
579
+ if (action === 'delete' || action.startsWith('remove')) return 'delete';
580
+ if (action === 'create') return 'create';
581
+ if (action === 'update') return 'update';
582
+ if (action === 'get' || action === 'list' || action.startsWith('list_')
583
+ || action === 'search' || action === 'timeline') return 'read';
584
+ return 'execute';
585
+ }
586
+ if (/^(delete|remove|bulk_delete)/.test(name)) return 'delete';
587
+ if (name.startsWith('create')) return 'create';
588
+ if (name.startsWith('update')) return 'update';
589
+ return 'execute';
590
+ }
591
+
592
+ /**
593
+ * Tools that must be audited even though their annotations say read-only.
594
+ *
595
+ * Mass-egress reads move whole datasets out of the workspace and belong in the
596
+ * audit trail; the extensibility inspection tools were audited under the old
597
+ * hand-kept list and stay audited (their headless-operation policy declares
598
+ * audit events for them).
599
+ */
600
+ const AUDIT_ALWAYS = new Set([
601
+ 'export_data', 'bulk_import',
602
+ 'inspect_provider_catalog', 'list_provider_endpoints',
603
+ ]);
604
+
605
+ /**
606
+ * Whether a tool call must be written to the audit log.
607
+ *
608
+ * Derived from the tool's real behaviour annotations (anything that is not
609
+ * read-only gets audited), NOT from a hand-kept allowlist. The old `crudTools`
610
+ * set silently excluded the most dangerous tools on the server —
611
+ * adrata_api_request, export_data, bulk_import, enroll_contacts,
612
+ * purchase_domain, paper_send_email, every partner write, and the paper
613
+ * deletes — so none of them left an audit trail.
614
+ *
615
+ * @param {string} name
616
+ * @returns {boolean}
617
+ */
618
+ export function mustAuditTool(name) {
619
+ if (AUDIT_ALWAYS.has(name)) return true;
620
+ return !buildToolAnnotations(name).readOnlyHint;
621
+ }
622
+
623
+ export function applySecurityLayer(server, authOrProvider, apiFn) {
624
+ const getAuth = typeof authOrProvider === 'function' ? authOrProvider : () => authOrProvider;
625
+ const auditLogger = new AuditLogger(apiFn);
626
+ const localSessionKey = `stdio-${crypto.randomUUID()}`;
627
+
628
+ function securityContext() {
629
+ const auth = getAuth() || { tier: 'free' };
630
+ // Hosted transport supplies an opaque, process-keyed principal. Never use
631
+ // bearer tokens or API keys themselves as rate-limit map keys.
632
+ const sessionKey = auth.securityPrincipal || localSessionKey;
633
+ return { auth, sessionKey };
634
+ }
635
+
636
+ // Wrap server.tool to add security layers around every handler
637
+ const _currentTool = server.tool.bind(server);
638
+ server.tool = function securedTool(name, ...rest) {
639
+ const handler = rest[rest.length - 1];
640
+ rest[rest.length - 1] = async function securedHandler(...handlerArgs) {
641
+ const { auth, sessionKey } = securityContext();
642
+ // --- Rate limiting ---
643
+ const rateCheck = checkRateLimit(sessionKey, auth.tier);
644
+ if (!rateCheck.allowed) {
645
+ return rateLimitResponse(rateCheck.retryAfterMs);
646
+ }
647
+
648
+ // --- Input validation ---
649
+ // handlerArgs[0] is the tool arguments object
650
+ const rawArgs = handlerArgs[0] || {};
651
+ const validation = validateToolInput(name, rawArgs);
652
+ if (!validation.valid) {
653
+ return validationErrorResponse(validation.error);
654
+ }
655
+ // Replace args with sanitized version
656
+ handlerArgs[0] = validation.args;
657
+
658
+ // --- Execute the tool ---
659
+ const result = await handler(...handlerArgs);
660
+
661
+ // --- Audit logging (enterprise tier; every non-read tool + mass egress) ---
662
+ if (auth.tier === 'enterprise' && mustAuditTool(name)) {
663
+ auditLogger.log({
664
+ tool: name,
665
+ action: auditActionFor(name, rawArgs),
666
+ tier: auth.tier,
667
+ userId: auth.userId || null,
668
+ sessionKey,
669
+ args: rawArgs,
670
+ });
671
+ }
672
+
673
+ return result;
674
+ };
675
+ return _currentTool(name, ...rest);
676
+ };
677
+
678
+ return { auditLogger, rateLimiter };
679
+ }
680
+
681
+ // ---------------------------------------------------------------------------
682
+ // Exports for testing
683
+ // ---------------------------------------------------------------------------
684
+
685
+ export {
686
+ SlidingWindowRateLimiter,
687
+ AuditLogger,
688
+ redactSensitiveFields,
689
+ TIER_RATE_LIMITS,
690
+ };