@matimo/core 0.1.0-alpha.11 → 0.1.0-alpha.12

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 (52) hide show
  1. package/dist/core/tool-loader.d.ts +3 -1
  2. package/dist/core/tool-loader.d.ts.map +1 -1
  3. package/dist/core/tool-loader.js +33 -10
  4. package/dist/core/tool-loader.js.map +1 -1
  5. package/dist/index.d.ts +5 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +5 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/integrations/langchain.d.ts.map +1 -1
  10. package/dist/integrations/langchain.js +5 -4
  11. package/dist/integrations/langchain.js.map +1 -1
  12. package/dist/mcp/index.d.ts +18 -0
  13. package/dist/mcp/index.d.ts.map +1 -0
  14. package/dist/mcp/index.js +24 -0
  15. package/dist/mcp/index.js.map +1 -0
  16. package/dist/mcp/mcp-server.d.ts +110 -0
  17. package/dist/mcp/mcp-server.d.ts.map +1 -0
  18. package/dist/mcp/mcp-server.js +646 -0
  19. package/dist/mcp/mcp-server.js.map +1 -0
  20. package/dist/mcp/secrets/aws-resolver.d.ts +41 -0
  21. package/dist/mcp/secrets/aws-resolver.d.ts.map +1 -0
  22. package/dist/mcp/secrets/aws-resolver.js +141 -0
  23. package/dist/mcp/secrets/aws-resolver.js.map +1 -0
  24. package/dist/mcp/secrets/dotenv-resolver.d.ts +23 -0
  25. package/dist/mcp/secrets/dotenv-resolver.d.ts.map +1 -0
  26. package/dist/mcp/secrets/dotenv-resolver.js +94 -0
  27. package/dist/mcp/secrets/dotenv-resolver.js.map +1 -0
  28. package/dist/mcp/secrets/env-resolver.d.ts +14 -0
  29. package/dist/mcp/secrets/env-resolver.d.ts.map +1 -0
  30. package/dist/mcp/secrets/env-resolver.js +27 -0
  31. package/dist/mcp/secrets/env-resolver.js.map +1 -0
  32. package/dist/mcp/secrets/index.d.ts +14 -0
  33. package/dist/mcp/secrets/index.d.ts.map +1 -0
  34. package/dist/mcp/secrets/index.js +13 -0
  35. package/dist/mcp/secrets/index.js.map +1 -0
  36. package/dist/mcp/secrets/resolver-chain.d.ts +34 -0
  37. package/dist/mcp/secrets/resolver-chain.d.ts.map +1 -0
  38. package/dist/mcp/secrets/resolver-chain.js +141 -0
  39. package/dist/mcp/secrets/resolver-chain.js.map +1 -0
  40. package/dist/mcp/secrets/types.d.ts +73 -0
  41. package/dist/mcp/secrets/types.d.ts.map +1 -0
  42. package/dist/mcp/secrets/types.js +8 -0
  43. package/dist/mcp/secrets/types.js.map +1 -0
  44. package/dist/mcp/secrets/vault-resolver.d.ts +43 -0
  45. package/dist/mcp/secrets/vault-resolver.d.ts.map +1 -0
  46. package/dist/mcp/secrets/vault-resolver.js +127 -0
  47. package/dist/mcp/secrets/vault-resolver.js.map +1 -0
  48. package/dist/mcp/tool-converter.d.ts +40 -0
  49. package/dist/mcp/tool-converter.d.ts.map +1 -0
  50. package/dist/mcp/tool-converter.js +176 -0
  51. package/dist/mcp/tool-converter.js.map +1 -0
  52. package/package.json +17 -1
@@ -0,0 +1,646 @@
1
+ /**
2
+ * Matimo MCP Server
3
+ *
4
+ * Exposes all Matimo tools via the Model Context Protocol.
5
+ * Supports stdio (local/Claude Desktop) and HTTP (remote/Docker) transports.
6
+ *
7
+ * Architecture:
8
+ * Client → MCP Protocol → MCPServer → MatimoInstance.execute() → Tool APIs
9
+ * Secrets resolved via SecretResolverChain (env, dotenv, Vault, AWS SM)
10
+ * HTTP mode protected by Bearer token (MATIMO_MCP_TOKEN)
11
+ */
12
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs';
13
+ import { join, dirname } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+ import { createRequire } from 'module';
16
+ import { MatimoInstance } from '../matimo-instance';
17
+ import { MatimoError, ErrorCode } from '../errors/matimo-error';
18
+ import { getGlobalMatimoLogger, setGlobalMatimoLogger } from '../logging';
19
+ import { createLogger } from '../logging/winston-logger';
20
+ import { toolToMcpRegistration, extractAuthPlaceholders } from './tool-converter';
21
+ import { createResolverChain } from './secrets/resolver-chain';
22
+ // ─── Helpers ────────────────────────────────────────────────────────────
23
+ function getPackageVersion() {
24
+ try {
25
+ // Method 1: Use createRequire to find @matimo/core's package.json (works in all environments)
26
+ try {
27
+ const req = typeof require !== 'undefined'
28
+ ? require
29
+ : // @ts-ignore - import.meta only available in ESM context
30
+ createRequire(eval('import.meta.url'));
31
+ const pkgPath = req.resolve('@matimo/core/package.json');
32
+ /* istanbul ignore next -- only reachable when @matimo/core/package.json is a proper export */
33
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
34
+ /* istanbul ignore next */
35
+ return pkg.version;
36
+ }
37
+ catch {
38
+ // Fall through to directory walking
39
+ }
40
+ // Method 2: Walk up from current file location
41
+ let currentDir;
42
+ if (typeof __dirname !== 'undefined') {
43
+ currentDir = __dirname;
44
+ }
45
+ else {
46
+ // istanbul ignore next -- ESM path, unreachable in CJS/Jest
47
+ try {
48
+ const metaUrl = eval('import.meta.url');
49
+ currentDir = dirname(fileURLToPath(metaUrl));
50
+ }
51
+ catch {
52
+ currentDir = process.cwd();
53
+ }
54
+ }
55
+ let dir = currentDir;
56
+ for (let i = 0; i < 6; i++) {
57
+ try {
58
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf-8'));
59
+ if (pkg.name === '@matimo/core')
60
+ return pkg.version;
61
+ }
62
+ catch {
63
+ // Continue walking up
64
+ }
65
+ dir = dirname(dir);
66
+ }
67
+ // Method 3: Fallback to well-known paths from cwd
68
+ // istanbul ignore next -- only reachable if both require.resolve and directory walking fail
69
+ const fallbackPaths = [
70
+ join(process.cwd(), 'packages', 'core', 'package.json'),
71
+ join(process.cwd(), 'node_modules', '@matimo', 'core', 'package.json'),
72
+ ];
73
+ for (const p of fallbackPaths) {
74
+ try {
75
+ const pkg = JSON.parse(readFileSync(p, 'utf-8'));
76
+ if (pkg.name === '@matimo/core')
77
+ return pkg.version;
78
+ }
79
+ catch {
80
+ // Continue
81
+ }
82
+ }
83
+ }
84
+ catch {
85
+ // Fallback
86
+ }
87
+ return '0.0.0';
88
+ }
89
+ // ─── MCPServer ──────────────────────────────────────────────────────────
90
+ export class MCPServer {
91
+ constructor(options = {}) {
92
+ this.matimo = null;
93
+ this.resolverChain = null;
94
+ this.mcpServer = null;
95
+ this.httpServer = null;
96
+ /** Filtered tools available for MCP registration */
97
+ this.filteredTools = [];
98
+ /** The active bearer token (explicit, env, or auto-generated) */
99
+ this.activeToken = null;
100
+ this.options = {
101
+ transport: options.transport ?? 'stdio',
102
+ port: options.port ?? 3000,
103
+ autoDiscover: options.autoDiscover ?? true,
104
+ https: options.https ?? false,
105
+ selfSigned: options.selfSigned ?? false,
106
+ ...options,
107
+ };
108
+ }
109
+ /** Get the active bearer token (available after start()) */
110
+ getActiveToken() {
111
+ return this.activeToken;
112
+ }
113
+ /**
114
+ * Start the MCP server.
115
+ *
116
+ * 1. Initialize secret resolver chain
117
+ * 2. Seed process.env with resolved secrets (for MatimoInstance compatibility)
118
+ * 3. Initialize MatimoInstance with tools
119
+ * 4. Register all tools on the MCP server
120
+ * 5. Connect transport (stdio or HTTP)
121
+ */
122
+ async start() {
123
+ // Step 0: Suppress logging in stdio mode (JSON-RPC protocol requires clean stdout/stderr)
124
+ if (this.options.transport === 'stdio') {
125
+ setGlobalMatimoLogger(createLogger({ logLevel: 'silent', logFormat: 'simple' }));
126
+ }
127
+ const logger = getGlobalMatimoLogger();
128
+ // Step 1: Build secret resolver chain
129
+ this.resolverChain = createResolverChain(this.options.secretResolver);
130
+ logger.info('Secret resolver chain initialized', {
131
+ resolvers: this.resolverChain.getResolvers().map((r) => r.name),
132
+ });
133
+ // Step 1b: Eagerly load dotenv into process.env so that server config
134
+ // values like MATIMO_MCP_TOKEN are available early (before tool registration).
135
+ // seedEnvironmentSecrets() only resolves tool-specific auth placeholders,
136
+ // so without this, .env-only config like MATIMO_MCP_TOKEN would be missed.
137
+ await this.resolverChain.seedProcessEnv();
138
+ // Step 2: Initialize Matimo
139
+ // Pass logLevel: 'silent' in stdio mode to prevent MatimoInstance from
140
+ // creating a non-silent logger that writes to stdout (corrupts JSON-RPC)
141
+ this.matimo = await MatimoInstance.init({
142
+ toolPaths: this.options.toolPaths,
143
+ autoDiscover: this.options.autoDiscover,
144
+ ...(this.options.transport === 'stdio' ? { logLevel: 'silent' } : {}),
145
+ });
146
+ // Re-set silent logger after init (MatimoInstance.init overwrites the global logger)
147
+ if (this.options.transport === 'stdio') {
148
+ setGlobalMatimoLogger(createLogger({ logLevel: 'silent', logFormat: 'simple' }));
149
+ }
150
+ // Step 3: Filter tools
151
+ let tools = this.matimo.listTools();
152
+ logger.debug(`MatimoInstance loaded ${tools.length} tools`);
153
+ if (this.options.tools && this.options.tools.length > 0) {
154
+ const allowSet = new Set(this.options.tools);
155
+ tools = tools.filter((t) => allowSet.has(t.name));
156
+ }
157
+ if (this.options.excludeTools && this.options.excludeTools.length > 0) {
158
+ const denySet = new Set(this.options.excludeTools);
159
+ tools = tools.filter((t) => !denySet.has(t.name));
160
+ }
161
+ if (tools.length === 0) {
162
+ logger.warn('No tools available after filtering. MCP server will have zero tools.');
163
+ }
164
+ // Step 4: Resolve auth placeholders for all tools and seed process.env
165
+ await this.seedEnvironmentSecrets(tools);
166
+ // Step 5: Store filtered tools for MCP server creation
167
+ this.filteredTools = tools;
168
+ // Step 6: Connect transport
169
+ // Each transport method creates its own McpServer instance(s).
170
+ // HTTP mode creates a new McpServer per session to support multiple concurrent clients.
171
+ if (this.options.transport === 'stdio') {
172
+ await this.connectStdio();
173
+ }
174
+ else {
175
+ await this.connectHttp();
176
+ }
177
+ }
178
+ /**
179
+ * Resolve all auth-related secrets for tools and seed them into process.env.
180
+ * This ensures MatimoInstance.injectAuthParameters() can find them.
181
+ */
182
+ async seedEnvironmentSecrets(tools) {
183
+ if (!this.resolverChain)
184
+ return;
185
+ const logger = getGlobalMatimoLogger();
186
+ // Collect all unique auth placeholders across all tools
187
+ const allPlaceholders = new Set();
188
+ for (const tool of tools) {
189
+ const placeholders = extractAuthPlaceholders(tool);
190
+ for (const p of placeholders) {
191
+ allPlaceholders.add(p);
192
+ }
193
+ }
194
+ if (allPlaceholders.size === 0)
195
+ return;
196
+ logger.debug('Resolving auth secrets for tools', {
197
+ placeholderCount: allPlaceholders.size,
198
+ });
199
+ // Resolve all at once (efficient for batch-capable resolvers)
200
+ const resolved = await this.resolverChain.resolveAll([...allPlaceholders]);
201
+ // Seed into process.env (only if not already set)
202
+ let seeded = 0;
203
+ for (const [key, value] of Object.entries(resolved)) {
204
+ if (!process.env[key]) {
205
+ process.env[key] = value;
206
+ seeded++;
207
+ }
208
+ // Also set MATIMO_ prefixed if not present
209
+ if (!process.env[`MATIMO_${key}`]) {
210
+ process.env[`MATIMO_${key}`] = value;
211
+ seeded++;
212
+ }
213
+ }
214
+ logger.debug('Auth secrets seeded into environment', {
215
+ resolved: Object.keys(resolved).length,
216
+ seeded,
217
+ });
218
+ }
219
+ /**
220
+ * Create a new McpServer instance with all filtered tools registered.
221
+ * Each call returns a fresh server — used per-session in HTTP mode.
222
+ */
223
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
224
+ async createMcpServerWithTools() {
225
+ // @ts-ignore - wildcard export subpath resolves at runtime via bundler moduleResolution
226
+ const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp');
227
+ const logger = getGlobalMatimoLogger();
228
+ const matimo = this.matimo;
229
+ const version = getPackageVersion();
230
+ const server = new McpServer({ name: 'matimo', version });
231
+ let registeredCount = 0;
232
+ for (const tool of this.filteredTools) {
233
+ try {
234
+ const registration = toolToMcpRegistration(tool);
235
+ server.registerTool(tool.name, {
236
+ title: registration.title,
237
+ description: registration.description,
238
+ inputSchema: registration.inputSchema,
239
+ }, async (args) => {
240
+ try {
241
+ logger.debug(`MCP tool call: ${tool.name}`, {
242
+ toolName: tool.name,
243
+ argCount: Object.keys(args).length,
244
+ });
245
+ if (tool.requires_approval) {
246
+ const approved = args._matimo_approved;
247
+ if (!approved) {
248
+ throw new MatimoError(`Tool '${tool.name}' requires approval. This is a destructive operation. Re-invoke with parameter _matimo_approved: true to confirm execution.`, ErrorCode.EXECUTION_FAILED);
249
+ }
250
+ }
251
+ const result = await matimo.execute(tool.name, args);
252
+ return {
253
+ content: [
254
+ {
255
+ type: 'text',
256
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
257
+ },
258
+ ],
259
+ };
260
+ }
261
+ catch (error) {
262
+ logger.error(`MCP tool call failed: ${tool.name}`, {
263
+ toolName: tool.name,
264
+ error: error instanceof Error ? error.message : String(error),
265
+ });
266
+ return {
267
+ content: [
268
+ {
269
+ type: 'text',
270
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
271
+ },
272
+ ],
273
+ isError: true,
274
+ };
275
+ }
276
+ });
277
+ registeredCount++;
278
+ }
279
+ catch (regError) {
280
+ logger.error(`Failed to register tool '${tool.name}'`, {
281
+ toolName: tool.name,
282
+ error: regError instanceof Error ? regError.message : String(regError),
283
+ });
284
+ }
285
+ }
286
+ logger.debug(`Registered ${registeredCount}/${this.filteredTools.length} tools on MCP server`);
287
+ return server;
288
+ }
289
+ /**
290
+ * Connect via stdio transport (for Claude Desktop, Cursor, etc.)
291
+ */
292
+ async connectStdio() {
293
+ // @ts-ignore - wildcard export subpath
294
+ const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio');
295
+ const server = await this.createMcpServerWithTools();
296
+ this.mcpServer = server;
297
+ const transport = new StdioServerTransport();
298
+ await server.connect(transport);
299
+ const logger = getGlobalMatimoLogger();
300
+ logger.info('Matimo MCP server started (stdio)', {
301
+ transport: 'stdio',
302
+ tools: this.filteredTools.length,
303
+ });
304
+ }
305
+ /**
306
+ * Connect via HTTP/HTTPS transport with Bearer token auth.
307
+ * Creates a new McpServer + transport per session to support multiple concurrent clients.
308
+ * Auto-generates a bearer token if none is provided.
309
+ */
310
+ async connectHttp() {
311
+ const http = await import('http');
312
+ const { StreamableHTTPServerTransport } = (await import(
313
+ // @ts-expect-error - optional peer dependency subpath not typed
314
+ '@modelcontextprotocol/sdk/server/streamableHttp'
315
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
316
+ ));
317
+ const { randomUUID } = await import('crypto');
318
+ const { isInitializeRequest } = (await import(
319
+ // @ts-expect-error - optional peer dependency subpath not typed
320
+ '@modelcontextprotocol/sdk/types'
321
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
322
+ ));
323
+ const logger = getGlobalMatimoLogger();
324
+ // Resolve bearer token: explicit > env > auto-generate
325
+ let mcpToken = this.options.mcpToken ?? process.env.MATIMO_MCP_TOKEN;
326
+ let tokenAutoGenerated = false;
327
+ if (!mcpToken) {
328
+ mcpToken = randomUUID();
329
+ tokenAutoGenerated = true;
330
+ logger.info('Auto-generated bearer token for HTTP mode');
331
+ }
332
+ this.activeToken = mcpToken;
333
+ // Determine protocol (HTTP vs HTTPS)
334
+ const useHttps = this.options.https || this.options.selfSigned || !!this.options.certPath;
335
+ const protocol = useHttps ? 'https' : 'http';
336
+ // Track active sessions: sessionId → { transport, server }
337
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
338
+ const sessions = new Map();
339
+ const requestHandler = async (req, res) => {
340
+ // CORS headers
341
+ res.setHeader('Access-Control-Allow-Origin', '*');
342
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
343
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id');
344
+ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
345
+ // Handle CORS preflight
346
+ if (req.method === 'OPTIONS') {
347
+ res.writeHead(204);
348
+ res.end();
349
+ return;
350
+ }
351
+ // Health check (no auth required)
352
+ if (req.url === '/health') {
353
+ res.writeHead(200, { 'Content-Type': 'application/json' });
354
+ res.end(JSON.stringify({ status: 'ok', tools: this.filteredTools.length }));
355
+ return;
356
+ }
357
+ // Bearer token auth (always enabled in HTTP mode)
358
+ const authHeader = req.headers.authorization;
359
+ if (!authHeader || authHeader !== `Bearer ${mcpToken}`) {
360
+ res.writeHead(401, { 'Content-Type': 'application/json' });
361
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
362
+ return;
363
+ }
364
+ // MCP endpoint
365
+ if (req.url === '/mcp' || req.url === '/') {
366
+ // Parse request body for POST requests
367
+ let body;
368
+ if (req.method === 'POST') {
369
+ try {
370
+ body = await new Promise((resolve, reject) => {
371
+ let data = '';
372
+ req.on('data', (chunk) => {
373
+ data += chunk;
374
+ });
375
+ req.on('end', () => {
376
+ try {
377
+ resolve(JSON.parse(data));
378
+ }
379
+ catch {
380
+ reject(new Error('Invalid JSON'));
381
+ }
382
+ });
383
+ req.on('error', reject);
384
+ });
385
+ }
386
+ catch (parseErr) {
387
+ const message = parseErr instanceof Error ? parseErr.message : 'Invalid request body';
388
+ res.writeHead(400, { 'Content-Type': 'application/json' });
389
+ res.end(JSON.stringify({
390
+ jsonrpc: '2.0',
391
+ error: { code: -32700, message },
392
+ id: null,
393
+ }));
394
+ return;
395
+ }
396
+ }
397
+ const sessionId = req.headers['mcp-session-id'];
398
+ // Route to existing session
399
+ if (sessionId && sessions.has(sessionId)) {
400
+ const session = sessions.get(sessionId);
401
+ await session.transport.handleRequest(req, res, body);
402
+ return;
403
+ }
404
+ // DELETE for unknown session
405
+ if (req.method === 'DELETE') {
406
+ res.writeHead(404, { 'Content-Type': 'application/json' });
407
+ res.end(JSON.stringify({ error: 'Session not found' }));
408
+ return;
409
+ }
410
+ // GET for SSE stream without session
411
+ if (req.method === 'GET') {
412
+ res.writeHead(400, { 'Content-Type': 'application/json' });
413
+ res.end(JSON.stringify({ error: 'Invalid or missing session ID' }));
414
+ return;
415
+ }
416
+ // Only create new session for initialization requests (official MCP pattern)
417
+ if (!isInitializeRequest(body)) {
418
+ res.writeHead(400, { 'Content-Type': 'application/json' });
419
+ res.end(JSON.stringify({
420
+ jsonrpc: '2.0',
421
+ error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
422
+ id: null,
423
+ }));
424
+ return;
425
+ }
426
+ // New session: create a fresh McpServer + transport.
427
+ // Declare mcpServer before constructing the transport so the onsessioninitialized
428
+ // closure captures the variable reference rather than an uninitialized binding (TDZ).
429
+ // eslint-disable-next-line prefer-const
430
+ let mcpServer;
431
+ const transport = new StreamableHTTPServerTransport({
432
+ sessionIdGenerator: () => randomUUID(),
433
+ onsessioninitialized: (sid) => {
434
+ sessions.set(sid, { transport, server: mcpServer });
435
+ logger.debug(`New MCP session: ${sid}`);
436
+ },
437
+ });
438
+ // Clean up session when transport closes
439
+ transport.onclose = () => {
440
+ const sid = transport.sessionId;
441
+ if (sid && sessions.has(sid)) {
442
+ sessions.delete(sid);
443
+ logger.debug(`MCP session closed: ${sid}`);
444
+ }
445
+ };
446
+ mcpServer = await this.createMcpServerWithTools();
447
+ await mcpServer.connect(transport);
448
+ // Handle the initialization request
449
+ await transport.handleRequest(req, res, body);
450
+ }
451
+ else {
452
+ res.writeHead(404, { 'Content-Type': 'application/json' });
453
+ res.end(JSON.stringify({ error: 'Not found. Use /mcp for MCP protocol.' }));
454
+ }
455
+ };
456
+ // Create HTTP or HTTPS server
457
+ let httpServer;
458
+ if (useHttps) {
459
+ const tlsOptions = await this.getTlsOptions();
460
+ const https = await import('https');
461
+ httpServer = https.createServer(tlsOptions, requestHandler);
462
+ }
463
+ else {
464
+ httpServer = http.createServer(requestHandler);
465
+ }
466
+ this.httpServer = httpServer;
467
+ const url = `${protocol}://localhost:${this.options.port}/mcp`;
468
+ await new Promise((resolve) => {
469
+ httpServer.listen(this.options.port, () => {
470
+ logger.info(`Matimo MCP server started (${protocol.toUpperCase()})`, {
471
+ transport: protocol,
472
+ port: this.options.port,
473
+ tools: this.filteredTools.length,
474
+ authenticated: true,
475
+ tokenAutoGenerated,
476
+ url,
477
+ });
478
+ resolve();
479
+ });
480
+ });
481
+ }
482
+ /**
483
+ * Get TLS options for HTTPS mode.
484
+ * Supports user-provided certs or auto-generated self-signed certs.
485
+ */
486
+ async getTlsOptions() {
487
+ // User-provided certificates
488
+ if (this.options.certPath && this.options.keyPath) {
489
+ if (!existsSync(this.options.certPath)) {
490
+ throw new MatimoError(`TLS certificate not found: ${this.options.certPath}`, ErrorCode.EXECUTION_FAILED);
491
+ }
492
+ if (!existsSync(this.options.keyPath)) {
493
+ throw new MatimoError(`TLS private key not found: ${this.options.keyPath}`, ErrorCode.EXECUTION_FAILED);
494
+ }
495
+ return {
496
+ cert: readFileSync(this.options.certPath, 'utf-8'),
497
+ key: readFileSync(this.options.keyPath, 'utf-8'),
498
+ };
499
+ }
500
+ // Self-signed certificate generation
501
+ return this.generateSelfSignedCert();
502
+ }
503
+ /**
504
+ * Generate a self-signed TLS certificate using Node.js crypto.
505
+ * Certs are cached in .matimo/certs/ for reuse across restarts.
506
+ */
507
+ async generateSelfSignedCert() {
508
+ const certsDir = join(process.cwd(), '.matimo', 'certs');
509
+ const certFile = join(certsDir, 'server.crt');
510
+ const keyFile = join(certsDir, 'server.key');
511
+ // Return cached certs if they exist
512
+ if (existsSync(certFile) && existsSync(keyFile)) {
513
+ const logger = getGlobalMatimoLogger();
514
+ logger.info('Using cached self-signed certificate from .matimo/certs/');
515
+ return {
516
+ cert: readFileSync(certFile, 'utf-8'),
517
+ key: readFileSync(keyFile, 'utf-8'),
518
+ };
519
+ }
520
+ // Generate new self-signed cert using Node.js crypto
521
+ const { generateKeyPairSync } = await import('crypto');
522
+ const logger = getGlobalMatimoLogger();
523
+ logger.info('Generating self-signed TLS certificate...');
524
+ // Generate RSA key pair
525
+ const { privateKey } = generateKeyPairSync('rsa', {
526
+ modulusLength: 2048,
527
+ });
528
+ // Build self-signed X.509 certificate using forge-free ASN.1
529
+ // For simplicity, use openssl via child_process if available, otherwise fallback
530
+ const keyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
531
+ const certPem = await this.createSelfSignedCertViaCli(keyPem);
532
+ // Save to .matimo/certs/
533
+ mkdirSync(certsDir, { recursive: true });
534
+ writeFileSync(certFile, certPem, { mode: 0o644 });
535
+ writeFileSync(keyFile, keyPem, { mode: 0o600 });
536
+ logger.info('Self-signed certificate saved to .matimo/certs/');
537
+ return { cert: certPem, key: keyPem };
538
+ }
539
+ /**
540
+ * Create a self-signed certificate using openssl CLI.
541
+ * Throws if openssl is unavailable or fails — provide --cert and --key paths as an alternative.
542
+ */
543
+ // istanbul ignore next -- requires openssl CLI; covered by integration tests
544
+ async createSelfSignedCertViaCli(keyPem) {
545
+ const { execFileSync } = await import('child_process');
546
+ const { tmpdir } = await import('os');
547
+ const { randomBytes } = await import('crypto');
548
+ const tmpKey = join(tmpdir(), `matimo-key-${randomBytes(4).toString('hex')}.pem`);
549
+ const tmpCert = join(tmpdir(), `matimo-cert-${randomBytes(4).toString('hex')}.pem`);
550
+ try {
551
+ writeFileSync(tmpKey, keyPem, { mode: 0o600 });
552
+ // Use execFileSync with an args array to avoid shell-specific redirection (e.g. 2>/dev/null)
553
+ // which is POSIX-only. stderr is suppressed via stdio: 'pipe'.
554
+ execFileSync('openssl', [
555
+ 'req',
556
+ '-new',
557
+ '-x509',
558
+ '-key',
559
+ tmpKey,
560
+ '-out',
561
+ tmpCert,
562
+ '-days',
563
+ '365',
564
+ '-subj',
565
+ '/CN=localhost/O=Matimo MCP Server',
566
+ '-addext',
567
+ 'subjectAltName=DNS:localhost,IP:127.0.0.1',
568
+ ], { stdio: 'pipe' });
569
+ const cert = readFileSync(tmpCert, 'utf-8');
570
+ return cert;
571
+ }
572
+ catch (err) {
573
+ const reason = err instanceof Error ? err.message : String(err);
574
+ throw new MatimoError(`Failed to generate self-signed certificate: ${reason}. Install openssl or provide --cert and --key paths.`, ErrorCode.EXECUTION_FAILED);
575
+ }
576
+ finally {
577
+ // Clean up temp files
578
+ try {
579
+ const { unlinkSync } = await import('fs');
580
+ unlinkSync(tmpKey);
581
+ unlinkSync(tmpCert);
582
+ }
583
+ catch {
584
+ // Ignore cleanup errors
585
+ }
586
+ }
587
+ }
588
+ /**
589
+ * Gracefully stop the MCP server.
590
+ */
591
+ async stop() {
592
+ const logger = getGlobalMatimoLogger();
593
+ // Close MCP server
594
+ if (this.mcpServer) {
595
+ const server = this.mcpServer;
596
+ if (server.close) {
597
+ await server.close();
598
+ }
599
+ this.mcpServer = null;
600
+ }
601
+ // Close HTTP server.
602
+ // Proactively drain keep-alive and SSE connections so close() can complete.
603
+ // closeIdleConnections() (Node ≥ 18.2) ends idle keep-alive sockets; if active
604
+ // SSE streams are still open, closeAllConnections() (Node ≥ 18.2) forces them
605
+ // closed so the callback is guaranteed to fire.
606
+ if (this.httpServer) {
607
+ const server = this.httpServer;
608
+ if (typeof server.closeIdleConnections === 'function') {
609
+ server.closeIdleConnections();
610
+ }
611
+ if (typeof server.closeAllConnections === 'function') {
612
+ server.closeAllConnections();
613
+ }
614
+ await new Promise((resolve, reject) => {
615
+ server.close((err) => {
616
+ if (err) {
617
+ return reject(err);
618
+ }
619
+ resolve();
620
+ });
621
+ });
622
+ this.httpServer = null;
623
+ }
624
+ // Dispose secret resolvers (flush caches, close connections)
625
+ if (this.resolverChain) {
626
+ await this.resolverChain.dispose();
627
+ this.resolverChain = null;
628
+ }
629
+ this.matimo = null;
630
+ logger.info('Matimo MCP server stopped');
631
+ }
632
+ /** Get the MatimoInstance (for testing) */
633
+ getMatimoInstance() {
634
+ return this.matimo;
635
+ }
636
+ }
637
+ /**
638
+ * Factory function to create and start an MCP server.
639
+ * Convenience for one-liner usage.
640
+ */
641
+ export async function createMCPServer(options) {
642
+ const server = new MCPServer(options);
643
+ await server.start();
644
+ return server;
645
+ }
646
+ //# sourceMappingURL=mcp-server.js.map