@pmoses-s1/sentinelone-mcp 1.0.0 → 1.2.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/index.js CHANGED
@@ -2,348 +2,171 @@
2
2
  /**
3
3
  * SentinelOne MCP Server
4
4
  *
5
- * Implements the Model Context Protocol (MCP) over stdio using raw JSON-RPC 2.0.
6
- * No external dependencies pure Node.js 18+.
5
+ * Implements the Model Context Protocol over either stdio (default) or
6
+ * Streamable HTTP, using raw JSON-RPC 2.0 throughout. No external runtime
7
+ * dependencies. Pure Node.js 18+.
7
8
  *
8
- * Exposes:
9
- * Resources : sentinelone://soc-context (CLAUDE.md SOC analyst operating instructions)
10
- * Prompts : soc_analyst (system prompt embedding CLAUDE.md)
11
- * Tools (21) : PowerQuery, Mgmt Console, SDL API, Hyperautomation, UAM Ingest
9
+ * Exposes 26 tools across PowerQuery, Mgmt Console REST, UAM, SDL API,
10
+ * Hyperautomation, and UAM Ingest; plus 2 resources and 2 prompts.
12
11
  *
13
- * Run as: node index.js
14
- * Configure in claude_desktop_config.json or .mcp.json see README.md.
12
+ * Quick start:
13
+ * stdio (default, used by Claude Desktop / Claude Code / Cowork):
14
+ * node index.js
15
+ *
16
+ * Streamable HTTP, bound to localhost, no auth (single-user local):
17
+ * node index.js --transport http
18
+ *
19
+ * Streamable HTTP, bound to 0.0.0.0 with team bearer tokens:
20
+ * MCP_BEARER_TOKENS_FILE=/etc/sentinelone-mcp/bearer-tokens.json \
21
+ * node index.js --transport http --host 0.0.0.0 --port 8765
22
+ *
23
+ * Full configuration reference: README.md and deploy/README.md.
15
24
  */
16
25
 
17
- import { createInterface } from 'readline';
18
- import { readFileSync, existsSync } from 'fs';
19
- import { join, dirname } from 'path';
20
- import { fileURLToPath } from 'url';
21
-
22
- import { tools as pqTools } from './tools/powerquery.js';
23
- import { tools as mgmtTools } from './tools/mgmt-console.js';
24
- import { tools as sdlTools } from './tools/sdl-api.js';
25
- import { tools as haTools } from './tools/hyperautomation.js';
26
- import { tools as uamIngestTools } from './tools/uam-ingest.js';
26
+ import { dispatch, SERVER_INFO, ALL_TOOLS } from './lib/server-core.js';
27
27
  import { getCreds, hasS1Creds, hasSdlCreds } from './lib/credentials.js';
28
28
  import { hasHecCreds } from './lib/uam-ingest.js';
29
-
30
- const __dir = dirname(fileURLToPath(import.meta.url));
31
-
32
- // ─── SOC context (CLAUDE.md) ──────────────────────────────────────────────────
33
-
34
- function loadSocContext() {
35
- // Resolution order (highest priority wins):
36
- // 1. S1_CLAUDE_MD_PATH env var (explicit override, e.g. set by claude_desktop_config.json)
37
- // 2. <cwd>/CLAUDE.md (Cowork project folder when launched from a project)
38
- // 3. relative to this server's install dir (works when running from a git clone)
39
- const candidates = [
40
- process.env.S1_CLAUDE_MD_PATH,
41
- process.cwd() ? join(process.cwd(), 'CLAUDE.md') : null,
42
- join(__dir, '..', 'CLAUDE.md'), // claude-skills/CLAUDE.md (git clone)
43
- join(__dir, '..', '..', 'CLAUDE.md'), // project root (git clone)
44
- join(__dir, 'CLAUDE.md'), // same dir
45
- ].filter(Boolean);
46
- for (const p of candidates) {
47
- if (existsSync(p)) {
48
- try { return readFileSync(p, 'utf-8'); } catch { /* skip */ }
49
- }
50
- }
51
- return '# SentinelOne SOC Analyst Context\n\n_CLAUDE.md not found. Place it in your Cowork project folder, or set S1_CLAUDE_MD_PATH to an absolute path._';
52
- }
53
-
54
- const SOC_CONTEXT = loadSocContext();
55
-
56
- // ─── Tool registry ────────────────────────────────────────────────────────────
57
-
58
- const ALL_TOOLS = [...pqTools, ...mgmtTools, ...sdlTools, ...haTools, ...uamIngestTools];
59
-
60
- // MCP tool schema (inputSchema is JSON Schema)
61
- const TOOL_DEFS = ALL_TOOLS.map(t => ({
62
- name: t.name,
63
- description: t.description,
64
- inputSchema: t.inputSchema,
65
- }));
66
-
67
- // Handler map
68
- const HANDLERS = Object.fromEntries(ALL_TOOLS.map(t => [t.name, t.handler]));
69
-
70
- // ─── Resources ────────────────────────────────────────────────────────────────
71
-
72
- const RESOURCES = [
73
- {
74
- uri: 'sentinelone://soc-context',
75
- name: 'SOC Analyst Operating Instructions',
76
- description: 'CLAUDE.md — Principal SOC Analyst operating instructions including investigation workflow, evidence discipline, anomaly detection playbook, MITRE ATT&CK mapping, and tool usage priorities.',
77
- mimeType: 'text/markdown',
78
- },
79
- {
80
- uri: 'sentinelone://credentials-status',
81
- name: 'Credential Configuration Status',
82
- description: 'Reports which credentials are configured and which API surfaces are available.',
83
- mimeType: 'application/json',
84
- },
85
- ];
86
-
87
- // ─── Prompts ──────────────────────────────────────────────────────────────────
88
-
89
- const PROMPTS = [
90
- {
91
- name: 'soc_analyst',
92
- description: 'Load the Principal SOC Analyst system context from CLAUDE.md. Call at the start of every security investigation session to prime the operating instructions, evidence discipline rules, investigation workflow, and tool usage priorities.',
93
- arguments: [],
94
- },
95
- {
96
- name: 'session_init',
97
- description: 'Structured session initialization prompt. Triggers mandatory data-source enumeration, alert triage, and schema discovery in parallel — mirroring the standard engagement workflow from the SOC playbook.',
98
- arguments: [],
99
- },
100
- ];
101
-
102
- // ─── MCP protocol ─────────────────────────────────────────────────────────────
103
-
104
- const SERVER_INFO = {
105
- name: 'sentinelone-mcp-server',
106
- version: '1.0.0',
107
- };
108
-
109
- const PROTOCOL_VERSION = '2024-11-05';
110
-
111
- function ok(id, result) {
112
- return { jsonrpc: '2.0', id, result };
113
- }
114
-
115
- function err(id, code, message, data) {
116
- return { jsonrpc: '2.0', id, error: { code, message, ...(data ? { data } : {}) } };
117
- }
118
-
119
- function send(obj) {
120
- process.stdout.write(JSON.stringify(obj) + '\n');
121
- }
29
+ import { loadTokens, installSighupReload } from './lib/auth.js';
122
30
 
123
31
  function log(...args) {
124
32
  process.stderr.write('[sentinelone-mcp] ' + args.join(' ') + '\n');
125
33
  }
126
34
 
127
- // ─── Method handlers ──────────────────────────────────────────────────────────
128
-
129
- async function dispatch(method, params, id) {
130
- switch (method) {
131
-
132
- case 'initialize': {
133
- return ok(id, {
134
- protocolVersion: PROTOCOL_VERSION,
135
- capabilities: {
136
- resources: { subscribe: false, listChanged: false },
137
- tools: { listChanged: false },
138
- prompts: { listChanged: false },
139
- },
140
- serverInfo: SERVER_INFO,
141
- instructions: 'SentinelOne MCP server providing PowerQuery, Mgmt Console API, SDL API, and Hyperautomation tools. Load the "soc_analyst" prompt at session start for full operating context.',
142
- });
143
- }
144
-
145
- case 'ping': {
146
- return ok(id, {});
147
- }
148
-
149
- case 'resources/list': {
150
- return ok(id, { resources: RESOURCES });
151
- }
152
-
153
- case 'resources/read': {
154
- const uri = params?.uri;
155
- if (uri === 'sentinelone://soc-context') {
156
- return ok(id, {
157
- contents: [{ uri, mimeType: 'text/markdown', text: SOC_CONTEXT }],
158
- });
159
- }
160
- if (uri === 'sentinelone://credentials-status') {
161
- const c = getCreds();
162
- const status = {
163
- s1MgmtApi: {
164
- configured: hasS1Creds(),
165
- consoleUrl: c.S1_CONSOLE_URL ? c.S1_CONSOLE_URL.replace(/https?:\/\//, '').split('.')[0] + '...' : 'NOT SET',
166
- tokenPresent: !!c.S1_CONSOLE_API_TOKEN,
167
- },
168
- sdlApi: {
169
- configured: hasSdlCreds(),
170
- xdrUrl: c.SDL_XDR_URL || 'NOT SET',
171
- configWriteKey: !!c.SDL_CONFIG_WRITE_KEY,
172
- logWriteKey: !!c.SDL_LOG_WRITE_KEY,
173
- },
174
- uamIngestApi: {
175
- configured: hasHecCreds(),
176
- hecUrl: c.S1_HEC_INGEST_URL || 'NOT SET (add S1_HEC_INGEST_URL to credentials.json)',
177
- tokenPresent: !!c.S1_CONSOLE_API_TOKEN,
178
- },
179
- };
180
- return ok(id, {
181
- contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(status, null, 2) }],
182
- });
183
- }
184
- return err(id, -32002, `Resource not found: ${uri}`);
35
+ // ─── CLI flag parser ──────────────────────────────────────────────────────────
36
+
37
+ function parseArgs(argv) {
38
+ const out = {
39
+ transport: process.env.MCP_TRANSPORT || 'stdio',
40
+ host: process.env.MCP_HTTP_HOST || '127.0.0.1',
41
+ port: Number(process.env.MCP_HTTP_PORT) || 8765,
42
+ path: process.env.MCP_HTTP_PATH || '/mcp',
43
+ help: false,
44
+ version: false,
45
+ };
46
+
47
+ for (let i = 0; i < argv.length; i++) {
48
+ const a = argv[i];
49
+ const next = () => argv[++i];
50
+ switch (a) {
51
+ case '-h': case '--help': out.help = true; break;
52
+ case '-v': case '--version': out.version = true; break;
53
+ case '--transport': out.transport = next(); break;
54
+ case '--host': out.host = next(); break;
55
+ case '--port': out.port = Number(next()); break;
56
+ case '--path': out.path = next(); break;
57
+ default:
58
+ if (a.startsWith('--')) {
59
+ process.stderr.write(`Unknown flag: ${a}\nRun with --help for usage.\n`);
60
+ process.exit(2);
61
+ }
185
62
  }
63
+ }
186
64
 
187
- case 'prompts/list': {
188
- return ok(id, { prompts: PROMPTS });
189
- }
190
-
191
- case 'prompts/get': {
192
- const name = params?.name;
193
-
194
- if (name === 'soc_analyst') {
195
- return ok(id, {
196
- description: 'Principal SOC Analyst operating instructions from CLAUDE.md',
197
- messages: [
198
- {
199
- role: 'user',
200
- content: {
201
- type: 'text',
202
- text: `You are operating as a Principal SOC Analyst. Load and follow the instructions below precisely.\n\n${SOC_CONTEXT}`,
203
- },
204
- },
205
- ],
206
- });
207
- }
208
-
209
- if (name === 'session_init') {
210
- return ok(id, {
211
- description: 'Structured session initialization',
212
- messages: [
213
- {
214
- role: 'user',
215
- content: {
216
- type: 'text',
217
- text: `Begin a new SOC analyst session. Follow this initialization sequence:
218
-
219
- 1. Call \`powerquery_enumerate_sources\` to discover active SDL data sources (MANDATORY — never assume sources from prior sessions).
220
- 2. In parallel, call \`uam_list_alerts\` with filter="status=OPEN" to pull active alerts.
221
- 3. For each discovered data source not already in the schema registry, plan schema discovery via \`powerquery_schema_discover\`.
222
- 4. Report: (a) active data sources list, (b) open alert count and top 5 by severity, (c) which sources need schema discovery.
223
-
224
- Apply the SOC analyst context from the soc_analyst prompt throughout.`,
225
- },
226
- },
227
- ],
228
- });
229
- }
230
-
231
- return err(id, -32002, `Prompt not found: ${name}`);
232
- }
233
-
234
- case 'tools/list': {
235
- return ok(id, { tools: TOOL_DEFS });
236
- }
237
-
238
- case 'tools/call': {
239
- const toolName = params?.name;
240
- const args = params?.arguments || {};
241
-
242
- if (!toolName) {
243
- return err(id, -32602, 'Missing tool name');
244
- }
245
-
246
- const handler = HANDLERS[toolName];
247
- if (!handler) {
248
- return err(id, -32602, `Tool not found: ${toolName}`);
249
- }
250
-
251
- try {
252
- const output = await handler(args);
253
- const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
254
- return ok(id, {
255
- content: [{ type: 'text', text }],
256
- isError: false,
257
- });
258
- } catch (e) {
259
- log(`Tool error [${toolName}]:`, e.message);
260
- return ok(id, {
261
- content: [{ type: 'text', text: `Error: ${e.message}` }],
262
- isError: true,
263
- });
264
- }
265
- }
65
+ if (!['stdio', 'http'].includes(out.transport)) {
66
+ process.stderr.write(`Invalid --transport: ${out.transport} (expected: stdio, http)\n`);
67
+ process.exit(2);
68
+ }
69
+ if (out.transport === 'http' && (!out.port || out.port <= 0 || out.port > 65535)) {
70
+ process.stderr.write(`Invalid --port: ${out.port}\n`);
71
+ process.exit(2);
72
+ }
73
+ if (!out.path.startsWith('/')) {
74
+ process.stderr.write(`Invalid --path: ${out.path} (must start with /)\n`);
75
+ process.exit(2);
76
+ }
266
77
 
267
- // Notifications have no id — just ignore
268
- case 'notifications/initialized':
269
- case 'initialized':
270
- return null;
78
+ return out;
79
+ }
271
80
 
272
- default: {
273
- if (id !== undefined) {
274
- return err(id, -32601, `Method not found: ${method}`);
275
- }
276
- return null;
277
- }
278
- }
81
+ function printHelp() {
82
+ process.stdout.write(`\
83
+ sentinelone-mcp ${SERVER_INFO.version}
84
+
85
+ USAGE
86
+ sentinelone-mcp [options]
87
+
88
+ OPTIONS
89
+ --transport <stdio|http> Transport to use. Default: stdio.
90
+ --host <host> HTTP bind address. Default: 127.0.0.1.
91
+ --port <port> HTTP port. Default: 8765.
92
+ --path <path> HTTP MCP endpoint path. Default: /mcp.
93
+ -h, --help Show this help.
94
+ -v, --version Show server version.
95
+
96
+ ENVIRONMENT
97
+ MCP_TRANSPORT Same as --transport.
98
+ MCP_HTTP_HOST Same as --host.
99
+ MCP_HTTP_PORT Same as --port.
100
+ MCP_HTTP_PATH Same as --path.
101
+
102
+ MCP_BEARER_TOKENS_FILE Path to a JSON file mapping {name: token} for
103
+ per-user authenticated HTTP access. Recommended
104
+ for teams. SIGHUP reloads without restart.
105
+ MCP_BEARER_TOKENS Comma-separated raw tokens (no per-user names).
106
+ Fallback when MCP_BEARER_TOKENS_FILE is not set.
107
+
108
+ S1_CONSOLE_URL Console URL, e.g. https://usea1-acme.sentinelone.net
109
+ S1_CONSOLE_API_TOKEN Mgmt Console API token. Required for most tools.
110
+ S1_HEC_INGEST_URL HEC ingest host. Required for uam_ingest_alert,
111
+ uam_post_indicators, uam_post_alert.
112
+ SDL_XDR_URL SDL tenant URL.
113
+ SDL_LOG_READ_KEY SDL Log Read key.
114
+ SDL_CONFIG_READ_KEY SDL Config Read key.
115
+ SDL_CONFIG_WRITE_KEY SDL Config Write key. Required for sdl_put_file.
116
+ S1_CREDS_FILE Explicit path to a credentials.json file.
117
+ Highest priority for credential resolution.
118
+ S1_CLAUDE_MD_PATH Absolute path to CLAUDE.md for the soc_analyst
119
+ prompt and sentinelone://soc-context resource.
120
+
121
+ EXAMPLES
122
+ Run as a local MCP server for Claude Desktop / Cowork:
123
+ sentinelone-mcp
124
+
125
+ Run as an HTTP service for personal use:
126
+ sentinelone-mcp --transport http
127
+ # then: curl -s http://127.0.0.1:8765/healthz
128
+
129
+ Run as a shared team service with token auth:
130
+ MCP_BEARER_TOKENS_FILE=/etc/sentinelone-mcp/bearer-tokens.json \\
131
+ sentinelone-mcp --transport http --host 0.0.0.0 --port 8765
132
+ # See deploy/README.md for the full Linux VM walkthrough.
133
+ `);
279
134
  }
280
135
 
281
- // ─── Main loop ────────────────────────────────────────────────────────────────
136
+ // ─── Main ────────────────────────────────────────────────────────────────────
282
137
 
283
138
  async function main() {
284
- log(`Starting (node ${process.version})`);
139
+ const opts = parseArgs(process.argv.slice(2));
140
+
141
+ if (opts.help) { printHelp(); process.exit(0); }
142
+ if (opts.version) { process.stdout.write(`${SERVER_INFO.version}\n`); process.exit(0); }
143
+
144
+ log(`Starting ${SERVER_INFO.name} v${SERVER_INFO.version} (node ${process.version})`);
285
145
 
286
146
  const creds = getCreds();
287
147
  log(`S1 Mgmt API: ${hasS1Creds() ? 'configured (' + creds.S1_CONSOLE_URL + ')' : 'NOT configured'}`);
288
148
  log(`SDL API: ${hasSdlCreds() ? 'configured (' + creds.SDL_XDR_URL + ')' : 'NOT configured'}`);
289
- log(`UAM Ingest: ${hasHecCreds() ? 'configured (' + creds.S1_HEC_INGEST_URL + ')' : 'NOT configured (add S1_HEC_INGEST_URL to credentials.json)'}`);
149
+ log(`UAM Ingest: ${hasHecCreds() ? 'configured (' + creds.S1_HEC_INGEST_URL + ')' : 'NOT configured (add S1_HEC_INGEST_URL)'}`);
290
150
  log(`Tools: ${ALL_TOOLS.length} registered`);
291
151
 
292
- const rl = createInterface({ input: process.stdin, terminal: false });
293
-
294
- // Track in-flight async requests so we don't exit while one is still running
295
- let inFlight = 0;
296
- let stdinClosed = false;
297
-
298
- function maybeExit() {
299
- if (stdinClosed && inFlight === 0) {
300
- log('All requests complete, exiting.');
301
- process.exit(0);
302
- }
303
- }
304
-
305
- rl.on('line', async (line) => {
306
- const trimmed = line.trim();
307
- if (!trimmed) return;
308
-
309
- let msg;
152
+ if (opts.transport === 'http') {
310
153
  try {
311
- msg = JSON.parse(trimmed);
154
+ loadTokens();
312
155
  } catch (e) {
313
- send(err(null, -32700, `Parse error: ${e.message}`));
314
- return;
156
+ process.stderr.write(`[auth] FATAL: ${e.message}\n`);
157
+ process.exit(1);
315
158
  }
159
+ installSighupReload();
316
160
 
317
- // Notification (no id) handle but don't respond
318
- const isNotification = msg.id === undefined;
319
-
320
- inFlight++;
321
- try {
322
- const response = await dispatch(msg.method, msg.params, msg.id);
323
- if (response !== null && !isNotification) {
324
- send(response);
325
- }
326
- } catch (e) {
327
- log('Unhandled dispatch error:', e.message, e.stack);
328
- if (!isNotification) {
329
- send(err(msg.id ?? null, -32603, `Internal error: ${e.message}`));
330
- }
331
- } finally {
332
- inFlight--;
333
- maybeExit();
334
- }
335
- });
336
-
337
- rl.on('close', () => {
338
- log('stdin closed, waiting for in-flight requests...');
339
- stdinClosed = true;
340
- maybeExit();
341
- });
161
+ const { startHttp } = await import('./lib/http-transport.js');
162
+ await startHttp(dispatch, { port: opts.port, host: opts.host, path: opts.path });
163
+ log('HTTP transport ready. Press Ctrl+C to stop.');
164
+ return;
165
+ }
342
166
 
343
- process.on('SIGINT', () => {
344
- log('SIGINT received, exiting.');
345
- process.exit(0);
346
- });
167
+ // Default: stdio
168
+ const { startStdio } = await import('./lib/stdio-transport.js');
169
+ await startStdio(dispatch);
347
170
  }
348
171
 
349
172
  main().catch(e => {
package/lib/auth.js ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Bearer token auth loader for HTTP transport.
3
+ *
4
+ * Sources (highest wins):
5
+ * 1. MCP_BEARER_TOKENS_FILE — path to JSON: { "<name>": "<token>", ... }
6
+ * Allows per-user tokens with stable names for audit logs.
7
+ * File mode is enforced via the install script (0600 recommended).
8
+ * 2. MCP_BEARER_TOKENS — comma-separated raw tokens (no per-user names).
9
+ * Names default to "token-1", "token-2", etc.
10
+ *
11
+ * If neither is set, HTTP transport runs with NO authentication. The server
12
+ * logs a loud warning at startup. Suitable only for stdio-style local-only
13
+ * deployments where the bind address is 127.0.0.1 and no other process can
14
+ * reach the port.
15
+ *
16
+ * SIGHUP reloads the token store without restarting the server, so rotation
17
+ * is "edit file, kill -HUP <pid>" with zero downtime.
18
+ *
19
+ * Zero dependencies. Synchronous load, async reload via SIGHUP.
20
+ */
21
+
22
+ import { readFileSync, existsSync, statSync } from 'fs';
23
+
24
+ let _tokens = new Map(); // token -> name
25
+ let _loadedFrom = null;
26
+ let _warnedNoAuth = false;
27
+
28
+ function parseFileTokens(path) {
29
+ const raw = readFileSync(path, 'utf-8');
30
+ const obj = JSON.parse(raw);
31
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
32
+ throw new Error('MCP_BEARER_TOKENS_FILE must contain a JSON object of {name: token}');
33
+ }
34
+ const m = new Map();
35
+ for (const [name, token] of Object.entries(obj)) {
36
+ if (typeof token !== 'string' || token.length < 16) {
37
+ throw new Error(`Token for "${name}" must be a string of at least 16 chars`);
38
+ }
39
+ m.set(token, name);
40
+ }
41
+ return m;
42
+ }
43
+
44
+ function parseEnvTokens(raw) {
45
+ const m = new Map();
46
+ const parts = raw.split(',').map(s => s.trim()).filter(Boolean);
47
+ parts.forEach((token, i) => {
48
+ if (token.length < 16) {
49
+ throw new Error(`Token #${i + 1} in MCP_BEARER_TOKENS must be at least 16 chars`);
50
+ }
51
+ m.set(token, `token-${i + 1}`);
52
+ });
53
+ return m;
54
+ }
55
+
56
+ function logModeForPath(path) {
57
+ try {
58
+ const s = statSync(path);
59
+ const mode = (s.mode & 0o777).toString(8);
60
+ if ((s.mode & 0o077) !== 0) {
61
+ process.stderr.write(
62
+ `[auth] WARNING: ${path} mode is ${mode}; recommended 0600. ` +
63
+ `Run: chmod 600 ${path}\n`
64
+ );
65
+ }
66
+ return mode;
67
+ } catch { return null; }
68
+ }
69
+
70
+ export function loadTokens() {
71
+ const filePath = process.env.MCP_BEARER_TOKENS_FILE;
72
+ const envRaw = process.env.MCP_BEARER_TOKENS;
73
+
74
+ if (filePath) {
75
+ if (!existsSync(filePath)) {
76
+ throw new Error(`MCP_BEARER_TOKENS_FILE points at non-existent path: ${filePath}`);
77
+ }
78
+ _tokens = parseFileTokens(filePath);
79
+ _loadedFrom = `file:${filePath}`;
80
+ const mode = logModeForPath(filePath);
81
+ process.stderr.write(
82
+ `[auth] Loaded ${_tokens.size} bearer token(s) from ${filePath} (mode ${mode || 'unknown'})\n`
83
+ );
84
+ return _tokens.size;
85
+ }
86
+
87
+ if (envRaw) {
88
+ _tokens = parseEnvTokens(envRaw);
89
+ _loadedFrom = 'env:MCP_BEARER_TOKENS';
90
+ process.stderr.write(
91
+ `[auth] Loaded ${_tokens.size} bearer token(s) from MCP_BEARER_TOKENS env var\n`
92
+ );
93
+ return _tokens.size;
94
+ }
95
+
96
+ _tokens = new Map();
97
+ _loadedFrom = null;
98
+ return 0;
99
+ }
100
+
101
+ /**
102
+ * Returns true if any token store is configured. When false, HTTP transport
103
+ * must either (a) refuse to start, or (b) start with a loud warning,
104
+ * depending on caller policy.
105
+ */
106
+ export function isAuthConfigured() {
107
+ return _tokens.size > 0;
108
+ }
109
+
110
+ /**
111
+ * Validates an Authorization header value and returns the matched token name
112
+ * (for audit logging), or null if the header is missing/invalid.
113
+ *
114
+ * Accepts: "Bearer <token>"
115
+ * Rejects: empty, malformed, unknown token.
116
+ */
117
+ export function authenticate(authHeader) {
118
+ if (!authHeader || typeof authHeader !== 'string') return null;
119
+ const m = authHeader.match(/^Bearer\s+(.+)$/i);
120
+ if (!m) return null;
121
+ const token = m[1].trim();
122
+ return _tokens.get(token) || null;
123
+ }
124
+
125
+ /**
126
+ * Warn once if HTTP transport is enabled with no auth. Caller invokes this
127
+ * at startup.
128
+ */
129
+ export function warnIfNoAuth(host) {
130
+ if (!isAuthConfigured() && !_warnedNoAuth) {
131
+ _warnedNoAuth = true;
132
+ const reachable = host !== '127.0.0.1' && host !== 'localhost';
133
+ process.stderr.write(
134
+ `[auth] WARNING: HTTP transport is running with NO authentication.\n` +
135
+ (reachable
136
+ ? `[auth] WARNING: bound to ${host} (reachable from other hosts). ` +
137
+ `Set MCP_BEARER_TOKENS_FILE or MCP_BEARER_TOKENS, or bind to 127.0.0.1.\n`
138
+ : `[auth] (Bound to ${host}; OK for purely local single-user use, ` +
139
+ `not OK for team / VM deployments.)\n`)
140
+ );
141
+ }
142
+ }
143
+
144
+ export function authSourceForLogging() {
145
+ return _loadedFrom;
146
+ }
147
+
148
+ /**
149
+ * Install a SIGHUP handler that reloads tokens from the same source.
150
+ * Returns nothing; intended to be called once at startup.
151
+ */
152
+ export function installSighupReload() {
153
+ process.on('SIGHUP', () => {
154
+ try {
155
+ const n = loadTokens();
156
+ process.stderr.write(`[auth] SIGHUP: reloaded, ${n} token(s) active\n`);
157
+ } catch (e) {
158
+ process.stderr.write(`[auth] SIGHUP: reload FAILED, keeping previous tokens: ${e.message}\n`);
159
+ }
160
+ });
161
+ }