@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/CHANGELOG.md +60 -0
- package/README.md +422 -102
- package/deploy/README.md +366 -0
- package/deploy/bridge/README.md +93 -0
- package/deploy/bridge/sentinelone-mcp-bridge.mjs +104 -0
- package/deploy/caddy/Caddyfile.example +110 -0
- package/deploy/install.sh +264 -0
- package/deploy/systemd/sentinelone-mcp.service +58 -0
- package/index.js +135 -312
- package/lib/auth.js +161 -0
- package/lib/credentials.js +18 -7
- package/lib/hec.js +128 -0
- package/lib/http-transport.js +223 -0
- package/lib/s1.js +1 -1
- package/lib/sdl.js +0 -32
- package/lib/server-core.js +264 -0
- package/lib/stdio-transport.js +77 -0
- package/lib/uam-ingest.js +1 -1
- package/package.json +10 -4
- package/scripts/regen-readme-tools-table.mjs +142 -0
- package/scripts/smoke-test-http.sh +122 -0
- package/scripts/test-mac.sh +179 -0
- package/tools/hyperautomation.js +1 -1
- package/tools/mgmt-console.js +30 -3
- package/tools/sdl-api.js +16 -24
package/index.js
CHANGED
|
@@ -2,348 +2,171 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* SentinelOne MCP Server
|
|
4
4
|
*
|
|
5
|
-
* Implements the Model Context Protocol
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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 {
|
|
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
|
-
// ───
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
case 'initialized':
|
|
270
|
-
return null;
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
271
80
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
136
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
282
137
|
|
|
283
138
|
async function main() {
|
|
284
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
154
|
+
loadTokens();
|
|
312
155
|
} catch (e) {
|
|
313
|
-
|
|
314
|
-
|
|
156
|
+
process.stderr.write(`[auth] FATAL: ${e.message}\n`);
|
|
157
|
+
process.exit(1);
|
|
315
158
|
}
|
|
159
|
+
installSighupReload();
|
|
316
160
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
+
}
|