@matimo/core 0.1.0-alpha.8 → 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.
Files changed (173) hide show
  1. package/README.md +341 -14
  2. package/dist/approval/approval-handler.d.ts +5 -1
  3. package/dist/approval/approval-handler.d.ts.map +1 -1
  4. package/dist/approval/approval-handler.js +6 -0
  5. package/dist/approval/approval-handler.js.map +1 -1
  6. package/dist/core/schema.d.ts +41 -10
  7. package/dist/core/schema.d.ts.map +1 -1
  8. package/dist/core/schema.js +40 -4
  9. package/dist/core/schema.js.map +1 -1
  10. package/dist/core/skill-content-parser.d.ts +91 -0
  11. package/dist/core/skill-content-parser.d.ts.map +1 -0
  12. package/dist/core/skill-content-parser.js +248 -0
  13. package/dist/core/skill-content-parser.js.map +1 -0
  14. package/dist/core/skill-loader.d.ts +46 -0
  15. package/dist/core/skill-loader.d.ts.map +1 -0
  16. package/dist/core/skill-loader.js +310 -0
  17. package/dist/core/skill-loader.js.map +1 -0
  18. package/dist/core/skill-registry.d.ts +131 -0
  19. package/dist/core/skill-registry.d.ts.map +1 -0
  20. package/dist/core/skill-registry.js +316 -0
  21. package/dist/core/skill-registry.js.map +1 -0
  22. package/dist/core/tfidf-embedding.d.ts +45 -0
  23. package/dist/core/tfidf-embedding.d.ts.map +1 -0
  24. package/dist/core/tfidf-embedding.js +199 -0
  25. package/dist/core/tfidf-embedding.js.map +1 -0
  26. package/dist/core/tool-loader.d.ts +3 -1
  27. package/dist/core/tool-loader.d.ts.map +1 -1
  28. package/dist/core/tool-loader.js +33 -10
  29. package/dist/core/tool-loader.js.map +1 -1
  30. package/dist/core/types.d.ts +203 -6
  31. package/dist/core/types.d.ts.map +1 -1
  32. package/dist/encodings/parameter-encoding.d.ts +1 -1
  33. package/dist/encodings/parameter-encoding.d.ts.map +1 -1
  34. package/dist/encodings/parameter-encoding.js +9 -4
  35. package/dist/encodings/parameter-encoding.js.map +1 -1
  36. package/dist/errors/matimo-error.d.ts +11 -2
  37. package/dist/errors/matimo-error.d.ts.map +1 -1
  38. package/dist/errors/matimo-error.js +25 -1
  39. package/dist/errors/matimo-error.js.map +1 -1
  40. package/dist/executors/command-executor.d.ts +9 -2
  41. package/dist/executors/command-executor.d.ts.map +1 -1
  42. package/dist/executors/command-executor.js +29 -5
  43. package/dist/executors/command-executor.js.map +1 -1
  44. package/dist/executors/function-executor.d.ts +10 -3
  45. package/dist/executors/function-executor.d.ts.map +1 -1
  46. package/dist/executors/function-executor.js +44 -24
  47. package/dist/executors/function-executor.js.map +1 -1
  48. package/dist/executors/http-executor.d.ts +79 -4
  49. package/dist/executors/http-executor.d.ts.map +1 -1
  50. package/dist/executors/http-executor.js +232 -28
  51. package/dist/executors/http-executor.js.map +1 -1
  52. package/dist/index.d.ts +25 -3
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +19 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/integrations/langchain.d.ts +55 -0
  57. package/dist/integrations/langchain.d.ts.map +1 -1
  58. package/dist/integrations/langchain.js +71 -4
  59. package/dist/integrations/langchain.js.map +1 -1
  60. package/dist/logging/logger.d.ts +8 -2
  61. package/dist/logging/logger.d.ts.map +1 -1
  62. package/dist/logging/logger.js.map +1 -1
  63. package/dist/logging/winston-logger.d.ts.map +1 -1
  64. package/dist/logging/winston-logger.js +9 -1
  65. package/dist/logging/winston-logger.js.map +1 -1
  66. package/dist/matimo-instance.d.ts +230 -18
  67. package/dist/matimo-instance.d.ts.map +1 -1
  68. package/dist/matimo-instance.js +739 -40
  69. package/dist/matimo-instance.js.map +1 -1
  70. package/dist/mcp/index.d.ts +18 -0
  71. package/dist/mcp/index.d.ts.map +1 -0
  72. package/dist/mcp/index.js +24 -0
  73. package/dist/mcp/index.js.map +1 -0
  74. package/dist/mcp/mcp-server.d.ts +141 -0
  75. package/dist/mcp/mcp-server.d.ts.map +1 -0
  76. package/dist/mcp/mcp-server.js +754 -0
  77. package/dist/mcp/mcp-server.js.map +1 -0
  78. package/dist/mcp/secrets/aws-resolver.d.ts +41 -0
  79. package/dist/mcp/secrets/aws-resolver.d.ts.map +1 -0
  80. package/dist/mcp/secrets/aws-resolver.js +141 -0
  81. package/dist/mcp/secrets/aws-resolver.js.map +1 -0
  82. package/dist/mcp/secrets/dotenv-resolver.d.ts +23 -0
  83. package/dist/mcp/secrets/dotenv-resolver.d.ts.map +1 -0
  84. package/dist/mcp/secrets/dotenv-resolver.js +94 -0
  85. package/dist/mcp/secrets/dotenv-resolver.js.map +1 -0
  86. package/dist/mcp/secrets/env-resolver.d.ts +14 -0
  87. package/dist/mcp/secrets/env-resolver.d.ts.map +1 -0
  88. package/dist/mcp/secrets/env-resolver.js +27 -0
  89. package/dist/mcp/secrets/env-resolver.js.map +1 -0
  90. package/dist/mcp/secrets/index.d.ts +14 -0
  91. package/dist/mcp/secrets/index.d.ts.map +1 -0
  92. package/dist/mcp/secrets/index.js +13 -0
  93. package/dist/mcp/secrets/index.js.map +1 -0
  94. package/dist/mcp/secrets/resolver-chain.d.ts +34 -0
  95. package/dist/mcp/secrets/resolver-chain.d.ts.map +1 -0
  96. package/dist/mcp/secrets/resolver-chain.js +141 -0
  97. package/dist/mcp/secrets/resolver-chain.js.map +1 -0
  98. package/dist/mcp/secrets/types.d.ts +73 -0
  99. package/dist/mcp/secrets/types.d.ts.map +1 -0
  100. package/dist/mcp/secrets/types.js +8 -0
  101. package/dist/mcp/secrets/types.js.map +1 -0
  102. package/dist/mcp/secrets/vault-resolver.d.ts +43 -0
  103. package/dist/mcp/secrets/vault-resolver.d.ts.map +1 -0
  104. package/dist/mcp/secrets/vault-resolver.js +127 -0
  105. package/dist/mcp/secrets/vault-resolver.js.map +1 -0
  106. package/dist/mcp/tool-converter.d.ts +40 -0
  107. package/dist/mcp/tool-converter.d.ts.map +1 -0
  108. package/dist/mcp/tool-converter.js +185 -0
  109. package/dist/mcp/tool-converter.js.map +1 -0
  110. package/dist/policy/approval-manifest.d.ts +76 -0
  111. package/dist/policy/approval-manifest.d.ts.map +1 -0
  112. package/dist/policy/approval-manifest.js +197 -0
  113. package/dist/policy/approval-manifest.js.map +1 -0
  114. package/dist/policy/content-validator.d.ts +19 -0
  115. package/dist/policy/content-validator.d.ts.map +1 -0
  116. package/dist/policy/content-validator.js +196 -0
  117. package/dist/policy/content-validator.js.map +1 -0
  118. package/dist/policy/default-policy.d.ts +46 -0
  119. package/dist/policy/default-policy.d.ts.map +1 -0
  120. package/dist/policy/default-policy.js +241 -0
  121. package/dist/policy/default-policy.js.map +1 -0
  122. package/dist/policy/events.d.ts +71 -0
  123. package/dist/policy/events.d.ts.map +1 -0
  124. package/dist/policy/events.js +8 -0
  125. package/dist/policy/events.js.map +1 -0
  126. package/dist/policy/index.d.ts +13 -0
  127. package/dist/policy/index.d.ts.map +1 -0
  128. package/dist/policy/index.js +9 -0
  129. package/dist/policy/index.js.map +1 -0
  130. package/dist/policy/integrity-tracker.d.ts +62 -0
  131. package/dist/policy/integrity-tracker.d.ts.map +1 -0
  132. package/dist/policy/integrity-tracker.js +79 -0
  133. package/dist/policy/integrity-tracker.js.map +1 -0
  134. package/dist/policy/policy-loader.d.ts +58 -0
  135. package/dist/policy/policy-loader.d.ts.map +1 -0
  136. package/dist/policy/policy-loader.js +156 -0
  137. package/dist/policy/policy-loader.js.map +1 -0
  138. package/dist/policy/risk-classifier.d.ts +18 -0
  139. package/dist/policy/risk-classifier.d.ts.map +1 -0
  140. package/dist/policy/risk-classifier.js +47 -0
  141. package/dist/policy/risk-classifier.js.map +1 -0
  142. package/dist/policy/types.d.ts +131 -0
  143. package/dist/policy/types.d.ts.map +1 -0
  144. package/dist/policy/types.js +8 -0
  145. package/dist/policy/types.js.map +1 -0
  146. package/package.json +22 -6
  147. package/tools/matimo_approve_tool/definition.yaml +36 -0
  148. package/tools/matimo_approve_tool/matimo_approve_tool.ts +90 -0
  149. package/tools/matimo_create_skill/definition.yaml +46 -0
  150. package/tools/matimo_create_skill/matimo_create_skill.ts +75 -0
  151. package/tools/matimo_create_tool/definition.yaml +48 -0
  152. package/tools/matimo_create_tool/matimo_create_tool.ts +137 -0
  153. package/tools/matimo_get_skill/definition.yaml +60 -0
  154. package/tools/matimo_get_skill/matimo_get_skill.ts +182 -0
  155. package/tools/matimo_get_tool/definition.yaml +36 -0
  156. package/tools/matimo_get_tool/matimo_get_tool.ts +56 -0
  157. package/tools/matimo_get_tool_status/definition.yaml +42 -0
  158. package/tools/matimo_get_tool_status/matimo_get_tool_status.ts +101 -0
  159. package/tools/matimo_list_skills/definition.yaml +52 -0
  160. package/tools/matimo_list_skills/matimo_list_skills.ts +138 -0
  161. package/tools/matimo_list_user_tools/definition.yaml +32 -0
  162. package/tools/matimo_list_user_tools/matimo_list_user_tools.ts +74 -0
  163. package/tools/matimo_reload_tools/definition.yaml +35 -0
  164. package/tools/matimo_reload_tools/matimo_reload_tools.ts +29 -0
  165. package/tools/matimo_search_tools/definition.yaml +32 -0
  166. package/tools/matimo_search_tools/matimo_search_tools.ts +82 -0
  167. package/tools/matimo_validate_skill/definition.yaml +43 -0
  168. package/tools/matimo_validate_skill/matimo_validate_skill.ts +137 -0
  169. package/tools/matimo_validate_tool/definition.yaml +34 -0
  170. package/tools/matimo_validate_tool/matimo_validate_tool.ts +168 -0
  171. package/tools/read/read.ts +0 -2
  172. package/tools/shared/skill-validation.ts +335 -0
  173. package/LICENSE +0 -21
@@ -0,0 +1,754 @@
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
+ : // ESM fallback (eval suppresses type errors)
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
+ /** Registered skill resources, keyed by skill name, for lifecycle management */
101
+ this.registeredSkillResources = new Map();
102
+ /** Resolved auth secrets held in memory — never written to process.env */
103
+ this.resolvedSecrets = {};
104
+ this.options = {
105
+ transport: options.transport ?? 'stdio',
106
+ port: options.port ?? 3000,
107
+ autoDiscover: options.autoDiscover ?? true,
108
+ https: options.https ?? false,
109
+ selfSigned: options.selfSigned ?? false,
110
+ ...options,
111
+ };
112
+ }
113
+ /** Get the active bearer token (available after start()) */
114
+ getActiveToken() {
115
+ return this.activeToken;
116
+ }
117
+ /**
118
+ * Hot-reload tools via MatimoInstance and re-register them on the MCP server.
119
+ * In stdio mode, calls sendToolListChanged() to notify the connected client.
120
+ */
121
+ async reloadTools() {
122
+ if (!this.matimo)
123
+ return;
124
+ const logger = getGlobalMatimoLogger();
125
+ const result = await this.matimo.reloadTools();
126
+ logger.info('MCPServer: tools reloaded via MatimoInstance', {
127
+ loaded: result.loaded,
128
+ removed: result.removed,
129
+ rejected: result.rejected.length,
130
+ });
131
+ // Re-filter tools applying allowSet/denySet
132
+ let tools = this.matimo.listTools();
133
+ if (this.options.tools && this.options.tools.length > 0) {
134
+ const allowSet = new Set(this.options.tools);
135
+ tools = tools.filter((t) => allowSet.has(t.name));
136
+ }
137
+ if (this.options.excludeTools && this.options.excludeTools.length > 0) {
138
+ const denySet = new Set(this.options.excludeTools);
139
+ tools = tools.filter((t) => !denySet.has(t.name));
140
+ }
141
+ this.filteredTools = tools;
142
+ // Notify MCP clients of tool list change (stdio only — single server)
143
+ if (this.mcpServer &&
144
+ typeof this.mcpServer.sendToolListChanged === 'function') {
145
+ this.mcpServer.sendToolListChanged();
146
+ logger.debug('Notified MCP client of tool list change');
147
+ }
148
+ // Re-sync skill resources on the stdio server (remove stale, add new)
149
+ if (this.mcpServer && this.matimo) {
150
+ this.registerSkillResources(this.mcpServer, this.matimo, logger);
151
+ if (typeof this.mcpServer.sendResourceListChanged === 'function') {
152
+ this.mcpServer.sendResourceListChanged();
153
+ logger.debug('Notified MCP client of resource list change');
154
+ }
155
+ }
156
+ }
157
+ /**
158
+ * Start the MCP server.
159
+ *
160
+ * 1. Initialize secret resolver chain
161
+ * 2. Seed process.env with resolved secrets (for MatimoInstance compatibility)
162
+ * 3. Initialize MatimoInstance with tools
163
+ * 4. Register all tools on the MCP server
164
+ * 5. Connect transport (stdio or HTTP)
165
+ */
166
+ async start() {
167
+ // Step 0: Suppress logging in stdio mode (JSON-RPC protocol requires clean stdout/stderr)
168
+ if (this.options.transport === 'stdio') {
169
+ setGlobalMatimoLogger(createLogger({ logLevel: 'silent', logFormat: 'simple' }));
170
+ }
171
+ const logger = getGlobalMatimoLogger();
172
+ // Step 1: Build secret resolver chain
173
+ this.resolverChain = createResolverChain(this.options.secretResolver);
174
+ logger.info('Secret resolver chain initialized', {
175
+ resolvers: this.resolverChain.getResolvers().map((r) => r.name),
176
+ });
177
+ // Step 1b: Eagerly load dotenv into process.env so that server config
178
+ // values like MATIMO_MCP_TOKEN are available early (before tool registration).
179
+ // seedEnvironmentSecrets() only resolves tool-specific auth placeholders,
180
+ // so without this, .env-only config like MATIMO_MCP_TOKEN would be missed.
181
+ await this.resolverChain.seedProcessEnv();
182
+ // Step 2: Initialize Matimo
183
+ // Pass logLevel: 'silent' in stdio mode to prevent MatimoInstance from
184
+ // creating a non-silent logger that writes to stdout (corrupts JSON-RPC)
185
+ this.matimo = await MatimoInstance.init({
186
+ toolPaths: this.options.toolPaths,
187
+ skillPaths: this.options.skillPaths,
188
+ autoDiscover: this.options.autoDiscover,
189
+ ...(this.options.transport === 'stdio' ? { logLevel: 'silent' } : {}),
190
+ ...(this.options.policyConfig ? { policyConfig: this.options.policyConfig } : {}),
191
+ ...(this.options.untrustedPaths ? { untrustedPaths: this.options.untrustedPaths } : {}),
192
+ ...(this.options.approvalSecret ? { approvalSecret: this.options.approvalSecret } : {}),
193
+ ...(this.options.approvalDir ? { approvalDir: this.options.approvalDir } : {}),
194
+ });
195
+ // Re-set silent logger after init (MatimoInstance.init overwrites the global logger)
196
+ if (this.options.transport === 'stdio') {
197
+ setGlobalMatimoLogger(createLogger({ logLevel: 'silent', logFormat: 'simple' }));
198
+ }
199
+ // Step 3: Filter tools
200
+ let tools = this.matimo.listTools();
201
+ logger.debug(`MatimoInstance loaded ${tools.length} tools`);
202
+ if (this.options.tools && this.options.tools.length > 0) {
203
+ const allowSet = new Set(this.options.tools);
204
+ tools = tools.filter((t) => allowSet.has(t.name));
205
+ }
206
+ if (this.options.excludeTools && this.options.excludeTools.length > 0) {
207
+ const denySet = new Set(this.options.excludeTools);
208
+ tools = tools.filter((t) => !denySet.has(t.name));
209
+ }
210
+ if (tools.length === 0) {
211
+ logger.warn('No tools available after filtering. MCP server will have zero tools.');
212
+ }
213
+ // Step 4: Resolve auth placeholders for all tools and seed process.env
214
+ await this.seedEnvironmentSecrets(tools);
215
+ // Step 5: Store filtered tools for MCP server creation
216
+ this.filteredTools = tools;
217
+ // Step 6: Connect transport
218
+ // Each transport method creates its own McpServer instance(s).
219
+ // HTTP mode creates a new McpServer per session to support multiple concurrent clients.
220
+ if (this.options.transport === 'stdio') {
221
+ await this.connectStdio();
222
+ }
223
+ else {
224
+ await this.connectHttp();
225
+ }
226
+ }
227
+ /**
228
+ * Resolve all auth-related secrets for tools and seed them into process.env.
229
+ * This ensures MatimoInstance.injectAuthParameters() can find them.
230
+ */
231
+ async seedEnvironmentSecrets(tools) {
232
+ if (!this.resolverChain)
233
+ return;
234
+ const logger = getGlobalMatimoLogger();
235
+ // Collect all unique auth placeholders across all tools
236
+ const allPlaceholders = new Set();
237
+ for (const tool of tools) {
238
+ const placeholders = extractAuthPlaceholders(tool);
239
+ for (const p of placeholders) {
240
+ allPlaceholders.add(p);
241
+ }
242
+ }
243
+ if (allPlaceholders.size === 0)
244
+ return;
245
+ logger.debug('Resolving auth secrets for tools', {
246
+ placeholderCount: allPlaceholders.size,
247
+ });
248
+ // Resolve all at once (efficient for batch-capable resolvers)
249
+ const resolved = await this.resolverChain.resolveAll([...allPlaceholders]);
250
+ // Store resolved secrets in memory only — never write to process.env.
251
+ // Secrets are injected as per-call credentials to matimo.execute() instead,
252
+ // preventing accidental leakage into child processes spawned by other code.
253
+ for (const [key, value] of Object.entries(resolved)) {
254
+ this.resolvedSecrets[key] = value;
255
+ this.resolvedSecrets[`MATIMO_${key}`] = value;
256
+ }
257
+ logger.debug('Auth secrets resolved and stored in memory (not in process.env)', {
258
+ resolved: Object.keys(resolved).length,
259
+ });
260
+ }
261
+ /**
262
+ * Register (or re-register) all skills from `matimo` as MCP resources on `server`.
263
+ * Removes any previously registered skill resources before adding the current set
264
+ * so that this method is safe to call multiple times (e.g., from reloadTools).
265
+ */
266
+ registerSkillResources(server, matimo, logger) {
267
+ // Remove stale skill resources registered from a previous call
268
+ for (const [, handle] of this.registeredSkillResources) {
269
+ try {
270
+ handle.remove();
271
+ }
272
+ catch {
273
+ // Ignore — resource may already have been removed
274
+ }
275
+ }
276
+ this.registeredSkillResources.clear();
277
+ const skills = matimo.listSkills();
278
+ let registered = 0;
279
+ for (const skill of skills) {
280
+ try {
281
+ const handle = server.registerResource(skill.name, `skills://${skill.name}`, {
282
+ title: skill.name,
283
+ description: skill.description,
284
+ mimeType: 'text/markdown',
285
+ }, async () => {
286
+ const content = matimo.getSkillContent(skill.name);
287
+ return {
288
+ contents: [
289
+ {
290
+ uri: `skills://${skill.name}`,
291
+ text: content ?? `Skill "${skill.name}" content unavailable`,
292
+ mimeType: 'text/markdown',
293
+ },
294
+ ],
295
+ };
296
+ });
297
+ this.registeredSkillResources.set(skill.name, handle);
298
+ registered++;
299
+ }
300
+ catch (err) {
301
+ logger.debug(`Failed to register skill resource '${skill.name}'`, {
302
+ skillName: skill.name,
303
+ error: err instanceof Error ? err.message : String(err),
304
+ });
305
+ }
306
+ }
307
+ if (registered > 0) {
308
+ logger.debug(`Registered ${registered} skill resources on MCP server`);
309
+ }
310
+ }
311
+ /**
312
+ * Create a new McpServer instance with all filtered tools registered.
313
+ * Each call returns a fresh server — used per-session in HTTP mode.
314
+ */
315
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
316
+ async createMcpServerWithTools() {
317
+ const { McpServer } = await import(
318
+ // @ts-ignore — TS2307: module not found at compile time, resolves at runtime
319
+ '@modelcontextprotocol/sdk/server/mcp');
320
+ const logger = getGlobalMatimoLogger();
321
+ const matimo = this.matimo;
322
+ const version = getPackageVersion();
323
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
324
+ const server = new McpServer({ name: 'matimo', version });
325
+ let registeredCount = 0;
326
+ for (const tool of this.filteredTools) {
327
+ try {
328
+ const registration = toolToMcpRegistration(tool);
329
+ server.registerTool(tool.name, {
330
+ title: registration.title,
331
+ description: registration.description,
332
+ inputSchema: registration.inputSchema,
333
+ }, async (args) => {
334
+ try {
335
+ logger.debug(`MCP tool call: ${tool.name}`, {
336
+ toolName: tool.name,
337
+ argCount: Object.keys(args).length,
338
+ });
339
+ if (tool.requires_approval) {
340
+ const approved = args._matimo_approved;
341
+ if (approved !== true) {
342
+ 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);
343
+ }
344
+ }
345
+ // Strip _matimo_approved from args before passing to execute.
346
+ // By default this client-supplied flag is only a confirmation
347
+ // prompt signal; it must not bypass server-side approval checks.
348
+ const { _matimo_approved, ...cleanArgs } = args;
349
+ const result = await matimo.execute(tool.name, cleanArgs, {
350
+ approved: this.options.trustClientApproval === true &&
351
+ tool.requires_approval === true &&
352
+ _matimo_approved === true,
353
+ credentials: this.resolvedSecrets,
354
+ });
355
+ return {
356
+ content: [
357
+ {
358
+ type: 'text',
359
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
360
+ },
361
+ ],
362
+ };
363
+ }
364
+ catch (error) {
365
+ logger.error(`MCP tool call failed: ${tool.name}`, {
366
+ toolName: tool.name,
367
+ error: error instanceof Error ? error.message : String(error),
368
+ });
369
+ return {
370
+ content: [
371
+ {
372
+ type: 'text',
373
+ text: `Error: ${error instanceof Error ? error.message : String(error)}`,
374
+ },
375
+ ],
376
+ isError: true,
377
+ };
378
+ }
379
+ });
380
+ registeredCount++;
381
+ }
382
+ catch (regError) {
383
+ logger.error(`Failed to register tool '${tool.name}'`, {
384
+ toolName: tool.name,
385
+ error: regError instanceof Error ? regError.message : String(regError),
386
+ });
387
+ }
388
+ }
389
+ logger.debug(`Registered ${registeredCount}/${this.filteredTools.length} tools on MCP server`);
390
+ // Register skills as MCP resources (skills://name)
391
+ // This allows agents (Claude Desktop, Cursor, etc.) to attach skills directly
392
+ // from the resource picker — no tool calls needed.
393
+ this.registerSkillResources(server, matimo, logger);
394
+ return server;
395
+ }
396
+ /**
397
+ * Connect via stdio transport (for Claude Desktop, Cursor, etc.)
398
+ */
399
+ async connectStdio() {
400
+ const { StdioServerTransport } = await import(
401
+ // @ts-ignore — TS2307: module not found at compile time, resolves at runtime
402
+ '@modelcontextprotocol/sdk/server/stdio');
403
+ const server = await this.createMcpServerWithTools();
404
+ this.mcpServer = server;
405
+ const transport = new StdioServerTransport();
406
+ await server.connect(transport);
407
+ const logger = getGlobalMatimoLogger();
408
+ logger.info('Matimo MCP server started (stdio)', {
409
+ transport: 'stdio',
410
+ tools: this.filteredTools.length,
411
+ });
412
+ }
413
+ /**
414
+ * Connect via HTTP/HTTPS transport with Bearer token auth.
415
+ * Creates a new McpServer + transport per session to support multiple concurrent clients.
416
+ * Auto-generates a bearer token if none is provided.
417
+ */
418
+ async connectHttp() {
419
+ const http = await import('http');
420
+ const { StreamableHTTPServerTransport } = (await import(
421
+ // @ts-ignore — TS2307: module not found at compile time, resolves at runtime
422
+ '@modelcontextprotocol/sdk/server/streamableHttp'
423
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
424
+ ));
425
+ const { randomUUID } = await import('crypto');
426
+ const { isInitializeRequest } = (await import(
427
+ // @ts-ignore — TS2307: module not found at compile time, resolves at runtime
428
+ '@modelcontextprotocol/sdk/types'
429
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
430
+ ));
431
+ const logger = getGlobalMatimoLogger();
432
+ // Resolve bearer token: explicit > env > auto-generate
433
+ let mcpToken = this.options.mcpToken ?? process.env.MATIMO_MCP_TOKEN;
434
+ let tokenAutoGenerated = false;
435
+ if (!mcpToken) {
436
+ mcpToken = randomUUID();
437
+ tokenAutoGenerated = true;
438
+ logger.info('Auto-generated bearer token for HTTP mode');
439
+ }
440
+ this.activeToken = mcpToken;
441
+ // Determine protocol (HTTP vs HTTPS)
442
+ const useHttps = this.options.https || this.options.selfSigned || !!this.options.certPath;
443
+ const protocol = useHttps ? 'https' : 'http';
444
+ // Track active sessions: sessionId → { transport, server }
445
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
446
+ const sessions = new Map();
447
+ const requestHandler = async (req, res) => {
448
+ // CORS headers
449
+ res.setHeader('Access-Control-Allow-Origin', '*');
450
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
451
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id');
452
+ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
453
+ // Handle CORS preflight
454
+ if (req.method === 'OPTIONS') {
455
+ res.writeHead(204);
456
+ res.end();
457
+ return;
458
+ }
459
+ // Health check (no auth required)
460
+ if (req.url === '/health') {
461
+ res.writeHead(200, { 'Content-Type': 'application/json' });
462
+ res.end(JSON.stringify({ status: 'ok', tools: this.filteredTools.length }));
463
+ return;
464
+ }
465
+ // Bearer token auth (always enabled in HTTP mode)
466
+ const authHeader = req.headers.authorization;
467
+ if (!authHeader || authHeader !== `Bearer ${mcpToken}`) {
468
+ res.writeHead(401, { 'Content-Type': 'application/json' });
469
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
470
+ return;
471
+ }
472
+ // MCP endpoint
473
+ if (req.url === '/mcp' || req.url === '/') {
474
+ // Parse request body for POST requests
475
+ let body;
476
+ if (req.method === 'POST') {
477
+ try {
478
+ body = await new Promise((resolve, reject) => {
479
+ let data = '';
480
+ req.on('data', (chunk) => {
481
+ data += chunk;
482
+ });
483
+ req.on('end', () => {
484
+ try {
485
+ resolve(JSON.parse(data));
486
+ }
487
+ catch {
488
+ reject(new Error('Invalid JSON'));
489
+ }
490
+ });
491
+ req.on('error', reject);
492
+ });
493
+ }
494
+ catch (parseErr) {
495
+ const message = parseErr instanceof Error ? parseErr.message : 'Invalid request body';
496
+ res.writeHead(400, { 'Content-Type': 'application/json' });
497
+ res.end(JSON.stringify({
498
+ jsonrpc: '2.0',
499
+ error: { code: -32700, message },
500
+ id: null,
501
+ }));
502
+ return;
503
+ }
504
+ }
505
+ const sessionId = req.headers['mcp-session-id'];
506
+ // Route to existing session
507
+ if (sessionId && sessions.has(sessionId)) {
508
+ const session = sessions.get(sessionId);
509
+ await session.transport.handleRequest(req, res, body);
510
+ return;
511
+ }
512
+ // DELETE for unknown session
513
+ if (req.method === 'DELETE') {
514
+ res.writeHead(404, { 'Content-Type': 'application/json' });
515
+ res.end(JSON.stringify({ error: 'Session not found' }));
516
+ return;
517
+ }
518
+ // GET for SSE stream without session
519
+ if (req.method === 'GET') {
520
+ res.writeHead(400, { 'Content-Type': 'application/json' });
521
+ res.end(JSON.stringify({ error: 'Invalid or missing session ID' }));
522
+ return;
523
+ }
524
+ // Only create new session for initialization requests (official MCP pattern)
525
+ if (!isInitializeRequest(body)) {
526
+ res.writeHead(400, { 'Content-Type': 'application/json' });
527
+ res.end(JSON.stringify({
528
+ jsonrpc: '2.0',
529
+ error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
530
+ id: null,
531
+ }));
532
+ return;
533
+ }
534
+ // New session: create a fresh McpServer + transport.
535
+ // Declare mcpServer before constructing the transport so the onsessioninitialized
536
+ // closure captures the variable reference rather than an uninitialized binding (TDZ).
537
+ // eslint-disable-next-line prefer-const
538
+ let mcpServer;
539
+ const transport = new StreamableHTTPServerTransport({
540
+ sessionIdGenerator: () => randomUUID(),
541
+ onsessioninitialized: (sid) => {
542
+ sessions.set(sid, { transport, server: mcpServer });
543
+ logger.debug(`New MCP session: ${sid}`);
544
+ },
545
+ });
546
+ // Clean up session when transport closes
547
+ transport.onclose = () => {
548
+ const sid = transport.sessionId;
549
+ if (sid && sessions.has(sid)) {
550
+ sessions.delete(sid);
551
+ logger.debug(`MCP session closed: ${sid}`);
552
+ }
553
+ };
554
+ mcpServer = await this.createMcpServerWithTools();
555
+ await mcpServer.connect(transport);
556
+ // Handle the initialization request
557
+ await transport.handleRequest(req, res, body);
558
+ }
559
+ else {
560
+ res.writeHead(404, { 'Content-Type': 'application/json' });
561
+ res.end(JSON.stringify({ error: 'Not found. Use /mcp for MCP protocol.' }));
562
+ }
563
+ };
564
+ // Create HTTP or HTTPS server
565
+ let httpServer;
566
+ if (useHttps) {
567
+ const tlsOptions = await this.getTlsOptions();
568
+ const https = await import('https');
569
+ httpServer = https.createServer(tlsOptions, requestHandler);
570
+ }
571
+ else {
572
+ httpServer = http.createServer(requestHandler);
573
+ }
574
+ this.httpServer = httpServer;
575
+ const url = `${protocol}://localhost:${this.options.port}/mcp`;
576
+ await new Promise((resolve) => {
577
+ httpServer.listen(this.options.port, () => {
578
+ logger.info(`Matimo MCP server started (${protocol.toUpperCase()})`, {
579
+ transport: protocol,
580
+ port: this.options.port,
581
+ tools: this.filteredTools.length,
582
+ authenticated: true,
583
+ tokenAutoGenerated,
584
+ url,
585
+ });
586
+ resolve();
587
+ });
588
+ });
589
+ }
590
+ /**
591
+ * Get TLS options for HTTPS mode.
592
+ * Supports user-provided certs or auto-generated self-signed certs.
593
+ */
594
+ async getTlsOptions() {
595
+ // User-provided certificates
596
+ if (this.options.certPath && this.options.keyPath) {
597
+ if (!existsSync(this.options.certPath)) {
598
+ throw new MatimoError(`TLS certificate not found: ${this.options.certPath}`, ErrorCode.EXECUTION_FAILED);
599
+ }
600
+ if (!existsSync(this.options.keyPath)) {
601
+ throw new MatimoError(`TLS private key not found: ${this.options.keyPath}`, ErrorCode.EXECUTION_FAILED);
602
+ }
603
+ return {
604
+ cert: readFileSync(this.options.certPath, 'utf-8'),
605
+ key: readFileSync(this.options.keyPath, 'utf-8'),
606
+ };
607
+ }
608
+ // Self-signed certificate generation
609
+ return this.generateSelfSignedCert();
610
+ }
611
+ /**
612
+ * Generate a self-signed TLS certificate using Node.js crypto.
613
+ * Certs are cached in .matimo/certs/ for reuse across restarts.
614
+ */
615
+ async generateSelfSignedCert() {
616
+ const certsDir = join(process.cwd(), '.matimo', 'certs');
617
+ const certFile = join(certsDir, 'server.crt');
618
+ const keyFile = join(certsDir, 'server.key');
619
+ // Return cached certs if they exist
620
+ if (existsSync(certFile) && existsSync(keyFile)) {
621
+ const logger = getGlobalMatimoLogger();
622
+ logger.info('Using cached self-signed certificate from .matimo/certs/');
623
+ return {
624
+ cert: readFileSync(certFile, 'utf-8'),
625
+ key: readFileSync(keyFile, 'utf-8'),
626
+ };
627
+ }
628
+ // Generate new self-signed cert using Node.js crypto
629
+ const { generateKeyPairSync } = await import('crypto');
630
+ const logger = getGlobalMatimoLogger();
631
+ logger.info('Generating self-signed TLS certificate...');
632
+ // Generate RSA key pair
633
+ const { privateKey } = generateKeyPairSync('rsa', {
634
+ modulusLength: 2048,
635
+ });
636
+ // Build self-signed X.509 certificate using forge-free ASN.1
637
+ // For simplicity, use openssl via child_process if available, otherwise fallback
638
+ const keyPem = privateKey.export({ type: 'pkcs8', format: 'pem' });
639
+ const certPem = await this.createSelfSignedCertViaCli(keyPem);
640
+ // Save to .matimo/certs/
641
+ mkdirSync(certsDir, { recursive: true });
642
+ writeFileSync(certFile, certPem, { mode: 0o644 });
643
+ writeFileSync(keyFile, keyPem, { mode: 0o600 });
644
+ logger.info('Self-signed certificate saved to .matimo/certs/');
645
+ return { cert: certPem, key: keyPem };
646
+ }
647
+ /**
648
+ * Create a self-signed certificate using openssl CLI.
649
+ * Throws if openssl is unavailable or fails — provide --cert and --key paths as an alternative.
650
+ */
651
+ // istanbul ignore next -- requires openssl CLI; covered by integration tests
652
+ async createSelfSignedCertViaCli(keyPem) {
653
+ const { execFileSync } = await import('child_process');
654
+ const { tmpdir } = await import('os');
655
+ const { randomBytes } = await import('crypto');
656
+ const tmpKey = join(tmpdir(), `matimo-key-${randomBytes(4).toString('hex')}.pem`);
657
+ const tmpCert = join(tmpdir(), `matimo-cert-${randomBytes(4).toString('hex')}.pem`);
658
+ try {
659
+ writeFileSync(tmpKey, keyPem, { mode: 0o600 });
660
+ // Use execFileSync with an args array to avoid shell-specific redirection (e.g. 2>/dev/null)
661
+ // which is POSIX-only. stderr is suppressed via stdio: 'pipe'.
662
+ execFileSync('openssl', [
663
+ 'req',
664
+ '-new',
665
+ '-x509',
666
+ '-key',
667
+ tmpKey,
668
+ '-out',
669
+ tmpCert,
670
+ '-days',
671
+ '365',
672
+ '-subj',
673
+ '/CN=localhost/O=Matimo MCP Server',
674
+ '-addext',
675
+ 'subjectAltName=DNS:localhost,IP:127.0.0.1',
676
+ ], { stdio: 'pipe' });
677
+ const cert = readFileSync(tmpCert, 'utf-8');
678
+ return cert;
679
+ }
680
+ catch (err) {
681
+ const reason = err instanceof Error ? err.message : String(err);
682
+ throw new MatimoError(`Failed to generate self-signed certificate: ${reason}. Install openssl or provide --cert and --key paths.`, ErrorCode.EXECUTION_FAILED);
683
+ }
684
+ finally {
685
+ // Clean up temp files
686
+ try {
687
+ const { unlinkSync } = await import('fs');
688
+ unlinkSync(tmpKey);
689
+ unlinkSync(tmpCert);
690
+ }
691
+ catch {
692
+ // Ignore cleanup errors
693
+ }
694
+ }
695
+ }
696
+ /**
697
+ * Gracefully stop the MCP server.
698
+ */
699
+ async stop() {
700
+ const logger = getGlobalMatimoLogger();
701
+ // Close MCP server
702
+ if (this.mcpServer) {
703
+ const server = this.mcpServer;
704
+ if (server.close) {
705
+ await server.close();
706
+ }
707
+ this.mcpServer = null;
708
+ }
709
+ // Close HTTP server.
710
+ // Proactively drain keep-alive and SSE connections so close() can complete.
711
+ // closeIdleConnections() (Node ≥ 18.2) ends idle keep-alive sockets; if active
712
+ // SSE streams are still open, closeAllConnections() (Node ≥ 18.2) forces them
713
+ // closed so the callback is guaranteed to fire.
714
+ if (this.httpServer) {
715
+ const server = this.httpServer;
716
+ if (typeof server.closeIdleConnections === 'function') {
717
+ server.closeIdleConnections();
718
+ }
719
+ if (typeof server.closeAllConnections === 'function') {
720
+ server.closeAllConnections();
721
+ }
722
+ await new Promise((resolve, reject) => {
723
+ server.close((err) => {
724
+ if (err) {
725
+ return reject(err);
726
+ }
727
+ resolve();
728
+ });
729
+ });
730
+ this.httpServer = null;
731
+ }
732
+ // Dispose secret resolvers (flush caches, close connections)
733
+ if (this.resolverChain) {
734
+ await this.resolverChain.dispose();
735
+ this.resolverChain = null;
736
+ }
737
+ this.matimo = null;
738
+ logger.info('Matimo MCP server stopped');
739
+ }
740
+ /** Get the MatimoInstance (for testing) */
741
+ getMatimoInstance() {
742
+ return this.matimo;
743
+ }
744
+ }
745
+ /**
746
+ * Factory function to create and start an MCP server.
747
+ * Convenience for one-liner usage.
748
+ */
749
+ export async function createMCPServer(options) {
750
+ const server = new MCPServer(options);
751
+ await server.start();
752
+ return server;
753
+ }
754
+ //# sourceMappingURL=mcp-server.js.map