@massu/core 0.6.2 → 0.6.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 55+ workflow commands, 11 agents, 20+ patterns",
6
6
  "main": "src/server.ts",
@@ -0,0 +1,459 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ import { spawn, type ChildProcess } from 'child_process';
5
+ import { readFileSync, existsSync } from 'fs';
6
+ import { resolve } from 'path';
7
+ import { getConfig, getProjectRoot } from './config.ts';
8
+ import type { ToolDefinition, ToolResult } from './tools.ts';
9
+
10
+ /** Prefix a base tool name with the configured tool prefix. */
11
+ function p(baseName: string): string {
12
+ return `${getConfig().toolPrefix}_${baseName}`;
13
+ }
14
+
15
+ // ============================================================
16
+ // MCP Bridge: Cross-project tool mesh
17
+ // ============================================================
18
+
19
+ interface MCPServerConfig {
20
+ command: string;
21
+ args?: string[];
22
+ cwd?: string;
23
+ type?: string;
24
+ }
25
+
26
+ interface MCPConnection {
27
+ config: MCPServerConfig;
28
+ process: ChildProcess | null;
29
+ connected: boolean;
30
+ tools: MCPToolDef[];
31
+ requestId: number;
32
+ }
33
+
34
+ interface MCPToolDef {
35
+ name: string;
36
+ description: string;
37
+ inputSchema: Record<string, unknown>;
38
+ }
39
+
40
+ // Active MCP connections (module-level to persist across tool calls)
41
+ const connections = new Map<string, MCPConnection>();
42
+
43
+ // Clean up all MCP subprocesses on exit to prevent orphans
44
+ process.on('exit', () => {
45
+ for (const [, conn] of connections) {
46
+ if (conn.process && !conn.process.killed) {
47
+ try { conn.process.kill('SIGTERM'); } catch { /* already dead */ }
48
+ }
49
+ }
50
+ });
51
+ process.on('SIGTERM', () => {
52
+ for (const [name] of connections) disconnectServer(name);
53
+ process.exit(0);
54
+ });
55
+
56
+ // Environment variables safe to forward to MCP subprocesses.
57
+ // Generic for any Massu npm user — no project-specific hardcoding.
58
+ const ENV_ALLOW_LIST = new Set([
59
+ 'PATH', 'HOME', 'LANG', 'LC_ALL', 'TERM',
60
+ 'PYTHONPATH', 'NODE_PATH',
61
+ ]);
62
+ const ENV_DENY_PATTERNS = [
63
+ 'PRIVATE_KEY', 'SECRET_KEY', 'API_SECRET',
64
+ 'AUTH_DISABLED', 'COLD_STORAGE',
65
+ 'PASSWORD', 'TOKEN',
66
+ ];
67
+
68
+ function buildSubprocessEnv(): Record<string, string> {
69
+ const env: Record<string, string> = {};
70
+ // Derive safe env prefixes from the project name in massu.config.yaml.
71
+ // e.g., project name "hedge" -> allow HEDGE_CONFIG_, HEDGE_LOG_ etc.
72
+ // This makes the bridge work for any Massu user's project without hardcoding.
73
+ const projectName = getConfig().project?.name?.toUpperCase() || '';
74
+ const safePrefixes = projectName
75
+ ? [`${projectName}_CONFIG_`, `${projectName}_LOG_`]
76
+ : [];
77
+
78
+ for (const [key, value] of Object.entries(process.env)) {
79
+ if (!value) continue;
80
+ if (ENV_DENY_PATTERNS.some(pat => key.includes(pat))) continue;
81
+ if (ENV_ALLOW_LIST.has(key)) {
82
+ env[key] = value;
83
+ } else if (safePrefixes.length > 0 && safePrefixes.some(pfx => key.startsWith(pfx))) {
84
+ env[key] = value;
85
+ }
86
+ }
87
+ return env;
88
+ }
89
+
90
+ /**
91
+ * Load MCP server configurations from .mcp.json in project root.
92
+ * Only loads servers that are NOT the massu server itself (avoid self-connection).
93
+ */
94
+ function loadMcpConfig(): Record<string, MCPServerConfig> {
95
+ const root = getProjectRoot();
96
+ const mcpPath = resolve(root, '.mcp.json');
97
+ if (!existsSync(mcpPath)) return {};
98
+
99
+ try {
100
+ const raw = JSON.parse(readFileSync(mcpPath, 'utf-8'));
101
+ const servers: Record<string, MCPServerConfig> = {};
102
+ const mcpServers = raw.mcpServers || {};
103
+ const pfx = getConfig().toolPrefix;
104
+
105
+ for (const [name, config] of Object.entries(mcpServers)) {
106
+ // Skip self (the massu server)
107
+ if (name === pfx) continue;
108
+ servers[name] = config as MCPServerConfig;
109
+ }
110
+ return servers;
111
+ } catch (e) {
112
+ console.error('[mcp-bridge] Failed to parse .mcp.json:', e);
113
+ return {};
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Send a JSON-RPC 2.0 request to an MCP subprocess and wait for response.
119
+ */
120
+ async function mcpRequest(
121
+ conn: MCPConnection,
122
+ method: string,
123
+ params: Record<string, unknown> = {},
124
+ ): Promise<Record<string, unknown>> {
125
+ if (!conn.process || !conn.process.stdin || !conn.process.stdout) {
126
+ throw new Error('MCP process not connected');
127
+ }
128
+
129
+ conn.requestId++;
130
+ const request = {
131
+ jsonrpc: '2.0',
132
+ id: conn.requestId,
133
+ method,
134
+ params,
135
+ };
136
+
137
+ return new Promise((resolve, reject) => {
138
+ const timeout = setTimeout(() => {
139
+ conn.process?.stdout?.removeListener('data', onData);
140
+ reject(new Error(`MCP request timed out: ${method}`));
141
+ }, 30000);
142
+
143
+ // Buffer partial chunks until we have a complete newline-delimited response
144
+ let buffer = '';
145
+ const onData = (data: Buffer) => {
146
+ buffer += data.toString('utf-8');
147
+ const lines = buffer.split('\n');
148
+ // Keep the last incomplete chunk in the buffer
149
+ buffer = lines.pop() || '';
150
+
151
+ for (const line of lines) {
152
+ if (!line.trim()) continue;
153
+ try {
154
+ const response = JSON.parse(line);
155
+ if (response.id === conn.requestId) {
156
+ clearTimeout(timeout);
157
+ conn.process?.stdout?.removeListener('data', onData);
158
+ if (response.error) {
159
+ reject(new Error(`MCP error ${response.error.code}: ${response.error.message}`));
160
+ } else {
161
+ resolve(response.result || {});
162
+ }
163
+ return;
164
+ }
165
+ } catch (e) {
166
+ clearTimeout(timeout);
167
+ conn.process?.stdout?.removeListener('data', onData);
168
+ reject(new Error(`Failed to parse MCP response: ${e}`));
169
+ return;
170
+ }
171
+ }
172
+ };
173
+
174
+ conn.process!.stdout!.on('data', onData);
175
+ conn.process!.stdin!.write(JSON.stringify(request) + '\n');
176
+ });
177
+ }
178
+
179
+ /**
180
+ * Connect to an MCP server subprocess and perform handshake.
181
+ */
182
+ async function connectToServer(name: string, config: MCPServerConfig): Promise<MCPConnection> {
183
+ const existing = connections.get(name);
184
+ if (existing?.connected && existing.process && !existing.process.killed) {
185
+ return existing;
186
+ }
187
+
188
+ const root = getProjectRoot();
189
+ const cwd = config.cwd ? resolve(root, config.cwd) : root;
190
+ const args = config.args || [];
191
+
192
+ const proc = spawn(config.command, args, {
193
+ cwd,
194
+ stdio: ['pipe', 'pipe', 'pipe'],
195
+ env: buildSubprocessEnv(),
196
+ });
197
+
198
+ const conn: MCPConnection = {
199
+ config,
200
+ process: proc,
201
+ connected: false,
202
+ tools: [],
203
+ requestId: 0,
204
+ };
205
+
206
+ try {
207
+ // Handshake
208
+ await mcpRequest(conn, 'initialize', {
209
+ protocolVersion: '2024-11-05',
210
+ capabilities: {},
211
+ clientInfo: { name: 'massu-mcp-bridge', version: '1.0.0' },
212
+ });
213
+
214
+ // Send initialized notification
215
+ if (proc.stdin) {
216
+ proc.stdin.write(JSON.stringify({
217
+ jsonrpc: '2.0',
218
+ method: 'notifications/initialized',
219
+ params: {},
220
+ }) + '\n');
221
+ }
222
+
223
+ conn.connected = true;
224
+
225
+ // Discover tools
226
+ const toolsResult = await mcpRequest(conn, 'tools/list', {}) as { tools?: MCPToolDef[] };
227
+ conn.tools = toolsResult.tools || [];
228
+
229
+ // Store in map only after successful handshake
230
+ connections.set(name, conn);
231
+ return conn;
232
+ } catch (e) {
233
+ // Clean up on handshake failure — don't leave a broken connection in the map
234
+ if (!proc.killed) proc.kill('SIGTERM');
235
+ throw e;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Disconnect an MCP server.
241
+ */
242
+ function disconnectServer(name: string): void {
243
+ const conn = connections.get(name);
244
+ if (conn) {
245
+ conn.connected = false;
246
+ if (conn.process && !conn.process.killed) {
247
+ conn.process.kill('SIGTERM');
248
+ // Escalate to SIGKILL if SIGTERM doesn't work within 3 seconds
249
+ const proc = conn.process;
250
+ setTimeout(() => {
251
+ if (!proc.killed) {
252
+ try { proc.kill('SIGKILL'); } catch { /* already dead */ }
253
+ }
254
+ }, 3000);
255
+ }
256
+ connections.delete(name);
257
+ }
258
+ }
259
+
260
+ function text(s: string): ToolResult {
261
+ return { content: [{ type: 'text', text: s }] };
262
+ }
263
+
264
+ // ============================================================
265
+ // Tool Definitions
266
+ // ============================================================
267
+
268
+ export function getMcpBridgeToolDefinitions(): ToolDefinition[] {
269
+ return [
270
+ {
271
+ name: p('mcp_servers'),
272
+ description: 'List all configured MCP servers from .mcp.json and their connection status.',
273
+ inputSchema: {
274
+ type: 'object',
275
+ properties: {},
276
+ required: [],
277
+ },
278
+ },
279
+ {
280
+ name: p('mcp_tools'),
281
+ description: 'List tools available from a specific MCP server. Connects to the server if not already connected.',
282
+ inputSchema: {
283
+ type: 'object',
284
+ properties: {
285
+ server: { type: 'string', description: 'MCP server name from .mcp.json' },
286
+ },
287
+ required: ['server'],
288
+ },
289
+ },
290
+ {
291
+ name: p('mcp_call'),
292
+ description: 'Call a tool on a connected MCP server. Connects automatically if needed.',
293
+ inputSchema: {
294
+ type: 'object',
295
+ properties: {
296
+ server: { type: 'string', description: 'MCP server name from .mcp.json' },
297
+ tool: { type: 'string', description: 'Tool name to call on the remote server' },
298
+ arguments: {
299
+ type: 'object',
300
+ description: 'Arguments to pass to the remote tool',
301
+ additionalProperties: true,
302
+ },
303
+ },
304
+ required: ['server', 'tool'],
305
+ },
306
+ },
307
+ {
308
+ name: p('mcp_status'),
309
+ description: 'Health check all MCP server connections. Shows which are connected, disconnected, or errored.',
310
+ inputSchema: {
311
+ type: 'object',
312
+ properties: {},
313
+ required: [],
314
+ },
315
+ },
316
+ ];
317
+ }
318
+
319
+ export function isMcpBridgeTool(name: string): boolean {
320
+ const pfx = getConfig().toolPrefix;
321
+ return name.startsWith(`${pfx}_mcp_`);
322
+ }
323
+
324
+ export async function handleMcpBridgeToolCall(
325
+ name: string,
326
+ args: Record<string, unknown>,
327
+ ): Promise<ToolResult> {
328
+ const pfx = getConfig().toolPrefix;
329
+ const baseName = name.startsWith(`${pfx}_`) ? name.slice(pfx.length + 1) : name;
330
+
331
+ switch (baseName) {
332
+ case 'mcp_servers':
333
+ return handleMcpServers();
334
+ case 'mcp_tools':
335
+ return await handleMcpTools(args.server as string);
336
+ case 'mcp_call':
337
+ return await handleMcpCall(
338
+ args.server as string,
339
+ args.tool as string,
340
+ (args.arguments as Record<string, unknown>) || {},
341
+ );
342
+ case 'mcp_status':
343
+ return handleMcpStatus();
344
+ default:
345
+ return text(`Unknown MCP bridge tool: ${name}`);
346
+ }
347
+ }
348
+
349
+ // ============================================================
350
+ // Tool Handlers
351
+ // ============================================================
352
+
353
+ function handleMcpServers(): ToolResult {
354
+ const configs = loadMcpConfig();
355
+ const servers = Object.entries(configs).map(([name, config]) => {
356
+ const conn = connections.get(name);
357
+ return {
358
+ name,
359
+ command: config.command,
360
+ args: config.args || [],
361
+ cwd: config.cwd || '.',
362
+ status: conn?.connected ? 'connected' : 'disconnected',
363
+ toolCount: conn?.tools.length || 0,
364
+ };
365
+ });
366
+
367
+ if (servers.length === 0) {
368
+ return text('No MCP servers configured in .mcp.json (excluding self).');
369
+ }
370
+
371
+ const lines = ['# MCP Servers\n'];
372
+ for (const srv of servers) {
373
+ const status = srv.status === 'connected' ? 'CONNECTED' : 'DISCONNECTED';
374
+ lines.push(`- **${srv.name}** [${status}] — \`${srv.command} ${srv.args.join(' ')}\` (${srv.toolCount} tools)`);
375
+ }
376
+ return text(lines.join('\n'));
377
+ }
378
+
379
+ async function handleMcpTools(server: string): Promise<ToolResult> {
380
+ const configs = loadMcpConfig();
381
+ const config = configs[server];
382
+ if (!config) {
383
+ return text(`MCP server "${server}" not found in .mcp.json. Available: ${Object.keys(configs).join(', ') || 'none'}`);
384
+ }
385
+
386
+ try {
387
+ const conn = await connectToServer(server, config);
388
+ if (conn.tools.length === 0) {
389
+ return text(`MCP server "${server}" is connected but has no tools.`);
390
+ }
391
+
392
+ const lines = [`# Tools from ${server} (${conn.tools.length})\n`];
393
+ for (const tool of conn.tools) {
394
+ lines.push(`- **${tool.name}**: ${tool.description}`);
395
+ }
396
+ return text(lines.join('\n'));
397
+ } catch (e) {
398
+ return text(`Failed to connect to MCP server "${server}": ${e}`);
399
+ }
400
+ }
401
+
402
+ async function handleMcpCall(
403
+ server: string,
404
+ tool: string,
405
+ args: Record<string, unknown>,
406
+ ): Promise<ToolResult> {
407
+ const configs = loadMcpConfig();
408
+ const config = configs[server];
409
+ if (!config) {
410
+ return text(`MCP server "${server}" not found in .mcp.json.`);
411
+ }
412
+
413
+ try {
414
+ const conn = await connectToServer(server, config);
415
+ const result = await mcpRequest(conn, 'tools/call', { name: tool, arguments: args });
416
+
417
+ // MCP tools/call returns { content: [...] } or { content: [...], isError: true }
418
+ const content = (result as any).content;
419
+ if (Array.isArray(content)) {
420
+ const texts = content
421
+ .filter((c: any) => c.type === 'text')
422
+ .map((c: any) => c.text);
423
+ if ((result as any).isError) {
424
+ return text(`MCP tool error: ${texts.join('\n')}`);
425
+ }
426
+ return text(texts.join('\n'));
427
+ }
428
+
429
+ return text(JSON.stringify(result, null, 2));
430
+ } catch (e) {
431
+ // Mark as disconnected but don't delete — operator can see the failure in mcp_status
432
+ const conn = connections.get(server);
433
+ if (conn) conn.connected = false;
434
+ return text(`MCP call failed (${server}/${tool}): ${e}`);
435
+ }
436
+ }
437
+
438
+ function handleMcpStatus(): ToolResult {
439
+ const configs = loadMcpConfig();
440
+ const lines = ['# MCP Connection Status\n'];
441
+
442
+ for (const [name] of Object.entries(configs)) {
443
+ const conn = connections.get(name);
444
+ if (!conn) {
445
+ lines.push(`- **${name}**: NOT CONNECTED`);
446
+ } else if (conn.connected && conn.process && !conn.process.killed) {
447
+ lines.push(`- **${name}**: HEALTHY (pid=${conn.process.pid}, ${conn.tools.length} tools)`);
448
+ } else {
449
+ lines.push(`- **${name}**: DISCONNECTED`);
450
+ disconnectServer(name);
451
+ }
452
+ }
453
+
454
+ if (Object.keys(configs).length === 0) {
455
+ lines.push('No MCP servers configured.');
456
+ }
457
+
458
+ return text(lines.join('\n'));
459
+ }
package/src/tools.ts CHANGED
@@ -39,6 +39,7 @@ import { getKnowledgeDb } from './knowledge-db.ts';
39
39
  import { getPythonToolDefinitions, isPythonTool, handlePythonToolCall } from './python-tools.ts';
40
40
  import { getConfig, getProjectRoot, getResolvedPaths } from './config.ts';
41
41
  import { getCurrentTier, getToolTier, isToolAllowed, annotateToolDefinitions, getLicenseToolDefinitions, isLicenseTool, handleLicenseToolCall } from './license.ts';
42
+ import { getMcpBridgeToolDefinitions, isMcpBridgeTool, handleMcpBridgeToolCall } from './mcp-bridge-tools.ts';
42
43
 
43
44
  export interface ToolDefinition {
44
45
  name: string;
@@ -166,6 +167,8 @@ export function getToolDefinitions(): ToolDefinition[] {
166
167
  ...getKnowledgeToolDefinitions(),
167
168
  // Python code intelligence tools
168
169
  ...getPythonToolDefinitions(),
170
+ // MCP bridge tools (cross-project tool mesh)
171
+ ...getMcpBridgeToolDefinitions(),
169
172
  // License tools (always available)
170
173
  ...getLicenseToolDefinitions(),
171
174
  // Core tools
@@ -380,6 +383,11 @@ export async function handleToolCall(
380
383
  return handlePythonToolCall(name, args, dataDb);
381
384
  }
382
385
 
386
+ // Route MCP bridge tools (cross-project tool mesh)
387
+ if (isMcpBridgeTool(name)) {
388
+ return await handleMcpBridgeToolCall(name, args);
389
+ }
390
+
383
391
  // Route license tools
384
392
  if (isLicenseTool(name)) {
385
393
  const memDb = getMemoryDb();