mcpsec 0.1.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.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * Sentinel MCP - Lightweight MCP Protocol Client
3
+ *
4
+ * Connects to MCP servers via stdio or HTTP transport,
5
+ * performs the initialization handshake, and queries
6
+ * tools/list, resources/list, prompts/list.
7
+ *
8
+ * Uses JSON-RPC 2.0 over newline-delimited JSON (stdio)
9
+ * or HTTP POST (streamable-http / SSE).
10
+ */
11
+
12
+ import { spawn, type Subprocess } from 'bun';
13
+ import type { MCPServerConfig } from './types';
14
+
15
+ // ============================================================================
16
+ // MCP Protocol Types
17
+ // ============================================================================
18
+
19
+ interface JsonRpcRequest {
20
+ jsonrpc: '2.0';
21
+ id: number;
22
+ method: string;
23
+ params?: Record<string, unknown>;
24
+ }
25
+
26
+ interface JsonRpcResponse {
27
+ jsonrpc: '2.0';
28
+ id: number;
29
+ result?: unknown;
30
+ error?: { code: number; message: string; data?: unknown };
31
+ }
32
+
33
+ export interface MCPTool {
34
+ name: string;
35
+ description?: string;
36
+ inputSchema?: Record<string, unknown>;
37
+ }
38
+
39
+ export interface MCPResource {
40
+ uri: string;
41
+ name?: string;
42
+ description?: string;
43
+ mimeType?: string;
44
+ }
45
+
46
+ export interface MCPPrompt {
47
+ name: string;
48
+ description?: string;
49
+ arguments?: Array<{ name: string; description?: string; required?: boolean }>;
50
+ }
51
+
52
+ export interface MCPServerInfo {
53
+ name?: string;
54
+ version?: string;
55
+ protocolVersion?: string;
56
+ capabilities?: Record<string, unknown>;
57
+ tools: MCPTool[];
58
+ resources: MCPResource[];
59
+ prompts: MCPPrompt[];
60
+ error?: string;
61
+ connectTimeMs?: number;
62
+ }
63
+
64
+ // ============================================================================
65
+ // Stdio Transport
66
+ // ============================================================================
67
+
68
+ const CONNECT_TIMEOUT = 10_000; // 10s to connect + initialize
69
+ const REQUEST_TIMEOUT = 5_000; // 5s per request
70
+
71
+ /**
72
+ * Connect to an MCP server via stdio transport
73
+ */
74
+ export async function connectStdio(
75
+ config: MCPServerConfig,
76
+ serverName: string
77
+ ): Promise<MCPServerInfo> {
78
+ const startTime = Date.now();
79
+
80
+ if (!config.command) {
81
+ return { tools: [], resources: [], prompts: [], error: 'No command specified' };
82
+ }
83
+
84
+ // Parse command - first word is the binary, rest are initial args
85
+ const parts = config.command.split(/\s+/);
86
+ const cmd = parts[0];
87
+ const cmdArgs = [...parts.slice(1), ...(config.args || [])];
88
+
89
+ let proc: Subprocess | null = null;
90
+
91
+ try {
92
+ // Spawn the server process
93
+ proc = spawn({
94
+ cmd: [cmd, ...cmdArgs],
95
+ env: { ...process.env, ...(config.env || {}) },
96
+ stdin: 'pipe',
97
+ stdout: 'pipe',
98
+ stderr: 'pipe',
99
+ });
100
+
101
+ const reader = new StdioReader(proc);
102
+ let messageId = 0;
103
+
104
+ // Helper: send JSON-RPC and wait for response
105
+ const sendRequest = async (method: string, params?: Record<string, unknown>): Promise<unknown> => {
106
+ const id = ++messageId;
107
+ const request: JsonRpcRequest = {
108
+ jsonrpc: '2.0',
109
+ id,
110
+ method,
111
+ params,
112
+ };
113
+
114
+ const line = JSON.stringify(request) + '\n';
115
+ proc!.stdin.write(line);
116
+ proc!.stdin.flush();
117
+
118
+ const response = await reader.waitForResponse(id, REQUEST_TIMEOUT);
119
+ if (response.error) {
120
+ throw new Error(`${method} failed: ${response.error.message}`);
121
+ }
122
+ return response.result;
123
+ };
124
+
125
+ // Helper: send notification (no response expected)
126
+ const sendNotification = (method: string, params?: Record<string, unknown>) => {
127
+ const notification = {
128
+ jsonrpc: '2.0' as const,
129
+ method,
130
+ params,
131
+ };
132
+ proc!.stdin.write(JSON.stringify(notification) + '\n');
133
+ proc!.stdin.flush();
134
+ };
135
+
136
+ // Step 1: Initialize
137
+ const initResult = await sendRequest('initialize', {
138
+ protocolVersion: '2024-11-05',
139
+ capabilities: {},
140
+ clientInfo: {
141
+ name: 'sentinel-mcp',
142
+ version: '0.1.0',
143
+ },
144
+ }) as Record<string, unknown>;
145
+
146
+ // Step 2: Send initialized notification
147
+ sendNotification('notifications/initialized');
148
+
149
+ // Step 3: Query tools, resources, prompts in parallel
150
+ const info: MCPServerInfo = {
151
+ name: (initResult?.serverInfo as Record<string, string>)?.name,
152
+ version: (initResult?.serverInfo as Record<string, string>)?.version,
153
+ protocolVersion: initResult?.protocolVersion as string,
154
+ capabilities: initResult?.capabilities as Record<string, unknown>,
155
+ tools: [],
156
+ resources: [],
157
+ prompts: [],
158
+ connectTimeMs: Date.now() - startTime,
159
+ };
160
+
161
+ // Query tools
162
+ try {
163
+ const toolsResult = await sendRequest('tools/list') as { tools?: MCPTool[] };
164
+ info.tools = toolsResult?.tools || [];
165
+ } catch {
166
+ // Server may not support tools
167
+ }
168
+
169
+ // Query resources
170
+ try {
171
+ const resourcesResult = await sendRequest('resources/list') as { resources?: MCPResource[] };
172
+ info.resources = resourcesResult?.resources || [];
173
+ } catch {
174
+ // Server may not support resources
175
+ }
176
+
177
+ // Query prompts
178
+ try {
179
+ const promptsResult = await sendRequest('prompts/list') as { prompts?: MCPPrompt[] };
180
+ info.prompts = promptsResult?.prompts || [];
181
+ } catch {
182
+ // Server may not support prompts
183
+ }
184
+
185
+ return info;
186
+ } catch (err) {
187
+ const error = err instanceof Error ? err.message : String(err);
188
+ return {
189
+ tools: [],
190
+ resources: [],
191
+ prompts: [],
192
+ error: `Failed to connect to "${serverName}": ${error}`,
193
+ connectTimeMs: Date.now() - startTime,
194
+ };
195
+ } finally {
196
+ // Clean up the process
197
+ if (proc) {
198
+ try {
199
+ proc.stdin.end();
200
+ proc.kill();
201
+ } catch {
202
+ // Process may have already exited
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ // ============================================================================
209
+ // Stdio Reader - Parses newline-delimited JSON-RPC from stdout
210
+ // ============================================================================
211
+
212
+ class StdioReader {
213
+ private buffer = '';
214
+ private pendingResponses = new Map<number, {
215
+ resolve: (r: JsonRpcResponse) => void;
216
+ reject: (e: Error) => void;
217
+ }>();
218
+ private reader: ReadableStreamDefaultReader<Uint8Array>;
219
+ private decoder = new TextDecoder();
220
+ private reading = false;
221
+
222
+ constructor(proc: Subprocess) {
223
+ this.reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
224
+ this.startReading();
225
+ }
226
+
227
+ private async startReading() {
228
+ if (this.reading) return;
229
+ this.reading = true;
230
+
231
+ try {
232
+ while (true) {
233
+ const { done, value } = await this.reader.read();
234
+ if (done) break;
235
+
236
+ this.buffer += this.decoder.decode(value, { stream: true });
237
+ this.processBuffer();
238
+ }
239
+ } catch {
240
+ // Stream closed
241
+ }
242
+ }
243
+
244
+ private processBuffer() {
245
+ const lines = this.buffer.split('\n');
246
+ // Keep the last incomplete line in the buffer
247
+ this.buffer = lines.pop() || '';
248
+
249
+ for (const line of lines) {
250
+ const trimmed = line.trim();
251
+ if (!trimmed) continue;
252
+
253
+ try {
254
+ const msg = JSON.parse(trimmed);
255
+ if (msg.id !== undefined && this.pendingResponses.has(msg.id)) {
256
+ const pending = this.pendingResponses.get(msg.id)!;
257
+ this.pendingResponses.delete(msg.id);
258
+ pending.resolve(msg as JsonRpcResponse);
259
+ }
260
+ // Ignore notifications from server (no id)
261
+ } catch {
262
+ // Skip non-JSON lines (server logs, etc.)
263
+ }
264
+ }
265
+ }
266
+
267
+ async waitForResponse(id: number, timeoutMs: number): Promise<JsonRpcResponse> {
268
+ return new Promise((resolve, reject) => {
269
+ const timer = setTimeout(() => {
270
+ this.pendingResponses.delete(id);
271
+ reject(new Error(`Request ${id} timed out after ${timeoutMs}ms`));
272
+ }, timeoutMs);
273
+
274
+ this.pendingResponses.set(id, {
275
+ resolve: (r) => {
276
+ clearTimeout(timer);
277
+ resolve(r);
278
+ },
279
+ reject: (e) => {
280
+ clearTimeout(timer);
281
+ reject(e);
282
+ },
283
+ });
284
+ });
285
+ }
286
+ }
287
+
288
+ // ============================================================================
289
+ // HTTP Transport
290
+ // ============================================================================
291
+
292
+ /**
293
+ * Connect to an MCP server via HTTP transport (SSE / streamable-http)
294
+ */
295
+ export async function connectHTTP(
296
+ config: MCPServerConfig,
297
+ serverName: string
298
+ ): Promise<MCPServerInfo> {
299
+ const startTime = Date.now();
300
+
301
+ if (!config.url) {
302
+ return { tools: [], resources: [], prompts: [], error: 'No URL specified' };
303
+ }
304
+
305
+ try {
306
+ let messageId = 0;
307
+
308
+ const sendRequest = async (method: string, params?: Record<string, unknown>): Promise<unknown> => {
309
+ const request: JsonRpcRequest = {
310
+ jsonrpc: '2.0',
311
+ id: ++messageId,
312
+ method,
313
+ params,
314
+ };
315
+
316
+ const response = await fetch(config.url!, {
317
+ method: 'POST',
318
+ headers: {
319
+ 'Content-Type': 'application/json',
320
+ ...(config.env?.AUTHORIZATION ? { Authorization: config.env.AUTHORIZATION } : {}),
321
+ },
322
+ body: JSON.stringify(request),
323
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT),
324
+ });
325
+
326
+ if (!response.ok) {
327
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
328
+ }
329
+
330
+ const result = await response.json() as JsonRpcResponse;
331
+ if (result.error) {
332
+ throw new Error(`${method} failed: ${result.error.message}`);
333
+ }
334
+ return result.result;
335
+ };
336
+
337
+ // Initialize
338
+ const initResult = await sendRequest('initialize', {
339
+ protocolVersion: '2024-11-05',
340
+ capabilities: {},
341
+ clientInfo: { name: 'sentinel-mcp', version: '0.1.0' },
342
+ }) as Record<string, unknown>;
343
+
344
+ const info: MCPServerInfo = {
345
+ name: (initResult?.serverInfo as Record<string, string>)?.name,
346
+ version: (initResult?.serverInfo as Record<string, string>)?.version,
347
+ protocolVersion: initResult?.protocolVersion as string,
348
+ capabilities: initResult?.capabilities as Record<string, unknown>,
349
+ tools: [],
350
+ resources: [],
351
+ prompts: [],
352
+ connectTimeMs: Date.now() - startTime,
353
+ };
354
+
355
+ // Query tools
356
+ try {
357
+ const toolsResult = await sendRequest('tools/list') as { tools?: MCPTool[] };
358
+ info.tools = toolsResult?.tools || [];
359
+ } catch { /* not supported */ }
360
+
361
+ // Query resources
362
+ try {
363
+ const resourcesResult = await sendRequest('resources/list') as { resources?: MCPResource[] };
364
+ info.resources = resourcesResult?.resources || [];
365
+ } catch { /* not supported */ }
366
+
367
+ // Query prompts
368
+ try {
369
+ const promptsResult = await sendRequest('prompts/list') as { prompts?: MCPPrompt[] };
370
+ info.prompts = promptsResult?.prompts || [];
371
+ } catch { /* not supported */ }
372
+
373
+ return info;
374
+ } catch (err) {
375
+ const error = err instanceof Error ? err.message : String(err);
376
+ return {
377
+ tools: [],
378
+ resources: [],
379
+ prompts: [],
380
+ error: `Failed to connect to "${serverName}" at ${config.url}: ${error}`,
381
+ connectTimeMs: Date.now() - startTime,
382
+ };
383
+ }
384
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Sentinel MCP - Core Type Definitions
3
+ */
4
+
5
+ // ============================================================================
6
+ // Severity & Risk
7
+ // ============================================================================
8
+
9
+ export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info';
10
+
11
+ export type ScanStatus = 'pass' | 'warn' | 'fail';
12
+
13
+ // ============================================================================
14
+ // MCP Configuration
15
+ // ============================================================================
16
+
17
+ export interface MCPServerConfig {
18
+ command?: string;
19
+ args?: string[];
20
+ env?: Record<string, string>;
21
+ url?: string;
22
+ transport?: 'stdio' | 'sse' | 'streamable-http';
23
+ }
24
+
25
+ export interface MCPConfigFile {
26
+ path: string;
27
+ client: string;
28
+ servers: Record<string, MCPServerConfig>;
29
+ raw: unknown;
30
+ }
31
+
32
+ // ============================================================================
33
+ // Scan Findings
34
+ // ============================================================================
35
+
36
+ export interface Finding {
37
+ id: string;
38
+ severity: Severity;
39
+ category: FindingCategory;
40
+ title: string;
41
+ description: string;
42
+ server?: string;
43
+ configFile?: string;
44
+ evidence?: string;
45
+ remediation?: string;
46
+ }
47
+
48
+ export type FindingCategory =
49
+ | 'credential-exposure'
50
+ | 'prompt-injection'
51
+ | 'tool-poisoning'
52
+ | 'ssrf'
53
+ | 'command-injection'
54
+ | 'insecure-transport'
55
+ | 'excessive-permissions'
56
+ | 'supply-chain'
57
+ | 'configuration';
58
+
59
+ // ============================================================================
60
+ // Scan Report
61
+ // ============================================================================
62
+
63
+ export interface ScanReport {
64
+ timestamp: string;
65
+ version: string;
66
+ score: number; // 0-100
67
+ status: ScanStatus;
68
+ configFiles: MCPConfigFile[];
69
+ findings: Finding[];
70
+ summary: ScanSummary;
71
+ }
72
+
73
+ export interface ScanSummary {
74
+ totalServers: number;
75
+ totalFindings: number;
76
+ critical: number;
77
+ high: number;
78
+ medium: number;
79
+ low: number;
80
+ info: number;
81
+ }
82
+
83
+ // ============================================================================
84
+ // Scanner Interface
85
+ // ============================================================================
86
+
87
+ export interface Scanner {
88
+ name: string;
89
+ scan(configs: MCPConfigFile[]): Promise<Finding[]>;
90
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Sentinel MCP - URL Validation & SSRF Protection
3
+ *
4
+ * Validates URLs found in MCP server configurations for SSRF,
5
+ * insecure transports, and suspicious patterns.
6
+ */
7
+
8
+ import type { Severity } from './types';
9
+
10
+ export interface URLValidationResult {
11
+ valid: boolean;
12
+ reason?: string;
13
+ warnings: string[];
14
+ severity?: Severity;
15
+ }
16
+
17
+ // ============================================================================
18
+ // SSRF Protection Configuration
19
+ // ============================================================================
20
+
21
+ const BLOCKED_HOSTS = [
22
+ 'localhost',
23
+ '127.0.0.1',
24
+ '0.0.0.0',
25
+ '::1',
26
+ '[::1]',
27
+ ];
28
+
29
+ const BLOCKED_IP_RANGES: RegExp[] = [
30
+ /^10\./, // 10.0.0.0/8 (RFC1918)
31
+ /^172\.(1[6-9]|2[0-9]|3[01])\./, // 172.16.0.0/12 (RFC1918)
32
+ /^192\.168\./, // 192.168.0.0/16 (RFC1918)
33
+ /^169\.254\./, // 169.254.0.0/16 (link-local)
34
+ /^100\.(6[4-9]|[7-9][0-9]|1[01][0-9]|12[0-7])\./, // 100.64.0.0/10 (CGNAT)
35
+ /^198\.51\.100\./, // 198.51.100.0/24 (TEST-NET-2)
36
+ /^203\.0\.113\./, // 203.0.113.0/24 (TEST-NET-3)
37
+ /^224\./, // 224.0.0.0/4 (multicast)
38
+ /^240\./, // 240.0.0.0/4 (reserved)
39
+ ];
40
+
41
+ const CLOUD_METADATA_ENDPOINTS = [
42
+ '169.254.169.254', // AWS/GCP/Azure metadata
43
+ 'metadata.google.internal',
44
+ 'metadata.google',
45
+ '100.100.100.200', // Alibaba Cloud metadata
46
+ ];
47
+
48
+ // ============================================================================
49
+ // Validation
50
+ // ============================================================================
51
+
52
+ /**
53
+ * Validate a URL for SSRF and security concerns
54
+ */
55
+ export function validateURL(url: string): URLValidationResult {
56
+ const warnings: string[] = [];
57
+
58
+ let parsed: URL;
59
+ try {
60
+ parsed = new URL(url);
61
+ } catch {
62
+ return { valid: false, reason: 'Invalid URL format', warnings };
63
+ }
64
+
65
+ const hostname = parsed.hostname.toLowerCase();
66
+ const scheme = parsed.protocol.replace(':', '').toLowerCase();
67
+
68
+ // Check scheme
69
+ if (!['https', 'http'].includes(scheme)) {
70
+ return {
71
+ valid: false,
72
+ reason: `Unsafe scheme: ${scheme}`,
73
+ warnings,
74
+ severity: 'high',
75
+ };
76
+ }
77
+
78
+ // SSRF: blocked hosts
79
+ if (BLOCKED_HOSTS.includes(hostname)) {
80
+ return {
81
+ valid: false,
82
+ reason: `SSRF risk: blocked host ${hostname}`,
83
+ warnings,
84
+ severity: 'critical',
85
+ };
86
+ }
87
+
88
+ // SSRF: cloud metadata endpoints (check before generic IP ranges for specific messaging)
89
+ if (CLOUD_METADATA_ENDPOINTS.includes(hostname)) {
90
+ return {
91
+ valid: false,
92
+ reason: `SSRF risk: cloud metadata endpoint ${hostname}`,
93
+ warnings,
94
+ severity: 'critical',
95
+ };
96
+ }
97
+
98
+ // SSRF: blocked IP ranges
99
+ for (const range of BLOCKED_IP_RANGES) {
100
+ if (range.test(hostname)) {
101
+ return {
102
+ valid: false,
103
+ reason: `SSRF risk: private/reserved IP range`,
104
+ warnings,
105
+ severity: 'critical',
106
+ };
107
+ }
108
+ }
109
+
110
+ // HTTP without TLS
111
+ if (scheme === 'http') {
112
+ warnings.push('Transport uses HTTP (unencrypted) - credentials and tokens may be exposed');
113
+ }
114
+
115
+ // Embedded credentials
116
+ if (parsed.username || parsed.password) {
117
+ warnings.push('URL contains embedded credentials');
118
+ }
119
+
120
+ // Non-standard port
121
+ if (parsed.port && !['80', '443', '8080', '8443'].includes(parsed.port)) {
122
+ warnings.push(`Non-standard port: ${parsed.port}`);
123
+ }
124
+
125
+ return {
126
+ valid: true,
127
+ warnings,
128
+ severity: warnings.length > 0 ? 'medium' : undefined,
129
+ };
130
+ }