@brutalist/mcp 0.8.1 → 0.9.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.
Files changed (35) hide show
  1. package/README.md +22 -1
  2. package/dist/brutalist-server.d.ts +46 -16
  3. package/dist/brutalist-server.d.ts.map +1 -1
  4. package/dist/brutalist-server.js +223 -611
  5. package/dist/brutalist-server.js.map +1 -1
  6. package/dist/cli-agents.d.ts +8 -6
  7. package/dist/cli-agents.d.ts.map +1 -1
  8. package/dist/cli-agents.js +236 -156
  9. package/dist/cli-agents.js.map +1 -1
  10. package/dist/domains/argument-space.d.ts +9 -0
  11. package/dist/domains/argument-space.d.ts.map +1 -1
  12. package/dist/domains/argument-space.js +27 -20
  13. package/dist/domains/argument-space.js.map +1 -1
  14. package/dist/formatting/response-formatter.d.ts +43 -0
  15. package/dist/formatting/response-formatter.d.ts.map +1 -0
  16. package/dist/formatting/response-formatter.js +277 -0
  17. package/dist/formatting/response-formatter.js.map +1 -0
  18. package/dist/generators/tool-generator.d.ts.map +1 -1
  19. package/dist/generators/tool-generator.js +3 -1
  20. package/dist/generators/tool-generator.js.map +1 -1
  21. package/dist/handlers/tool-handler.d.ts +33 -0
  22. package/dist/handlers/tool-handler.d.ts.map +1 -0
  23. package/dist/handlers/tool-handler.js +299 -0
  24. package/dist/handlers/tool-handler.js.map +1 -0
  25. package/dist/registry/argument-spaces.js +17 -17
  26. package/dist/registry/argument-spaces.js.map +1 -1
  27. package/dist/transport/http-transport.d.ts +40 -0
  28. package/dist/transport/http-transport.d.ts.map +1 -0
  29. package/dist/transport/http-transport.js +182 -0
  30. package/dist/transport/http-transport.js.map +1 -0
  31. package/dist/utils.d.ts +1 -1
  32. package/dist/utils.d.ts.map +1 -1
  33. package/dist/utils.js +13 -6
  34. package/dist/utils.js.map +1 -1
  35. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { spawn, exec } from 'child_process';
2
- import { realpathSync } from 'fs';
2
+ import { promises as fs, realpathSync } from 'fs';
3
3
  import { promisify } from 'util';
4
4
  import { logger } from './logger.js';
5
5
  // Configurable timeouts and limits
@@ -17,19 +17,37 @@ const activeProcesses = new Map();
17
17
  export const AVAILABLE_MODELS = {
18
18
  claude: {
19
19
  default: undefined, // Uses user's configured model (respects preferences)
20
- aliases: ['opus', 'sonnet', 'haiku'],
21
- full: ['claude-opus-4-1-20250805', 'claude-sonnet-4-20250514'],
22
- recommended: 'opus' // Highest capacity Claude model
20
+ aliases: ['opus', 'sonnet', 'haiku', 'opus-4.5', 'sonnet-4.5'],
21
+ full: [
22
+ 'claude-opus-4-5-20251101',
23
+ 'claude-sonnet-4-5-20250929',
24
+ 'claude-haiku-4-5-20251001',
25
+ 'claude-opus-4-1-20250805'
26
+ ],
27
+ recommended: 'claude-opus-4-5-20251101' // Highest capacity Claude model
23
28
  },
24
29
  codex: {
25
30
  default: undefined, // Uses Codex CLI's default model (stays current automatically)
26
- models: ['gpt-5.1-codex-max', 'gpt-5.1-codex', 'gpt-5.1-codex-mini', 'gpt-5-codex', 'gpt-5', 'o4-mini'],
31
+ models: [
32
+ 'gpt-5.1-codex-max',
33
+ 'gpt-5.1-codex',
34
+ 'gpt-5.1',
35
+ 'gpt-5-thinking-pro',
36
+ 'gpt-5',
37
+ 'gpt-5-mini',
38
+ 'o4-mini'
39
+ ],
27
40
  recommended: 'gpt-5.1-codex-max' // Current frontier model with compaction
28
41
  },
29
42
  gemini: {
30
43
  default: undefined, // Uses Gemini CLI's default model (stays current automatically)
31
- models: ['gemini-3-pro-preview', 'gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'],
32
- recommended: 'gemini-3-pro-preview' // Current #1 on LMArena
44
+ models: [
45
+ 'gemini-3-pro',
46
+ 'gemini-2.5-pro',
47
+ 'gemini-2.5-flash',
48
+ 'gemini-2.5-flash-lite'
49
+ ],
50
+ recommended: 'gemini-3-pro' // Current #1 on LMArena
33
51
  }
34
52
  };
35
53
  // Security utilities for CLI execution
@@ -72,6 +90,32 @@ function validatePath(path, name) {
72
90
  throw new Error(`Invalid ${name}: ${error instanceof Error ? error.message : String(error)}`);
73
91
  }
74
92
  }
93
+ // Async version of validatePath for use in async contexts
94
+ async function asyncValidatePath(path, name) {
95
+ if (!path) {
96
+ throw new Error(`${name} cannot be empty`);
97
+ }
98
+ // Check for null bytes
99
+ if (path.includes('\0')) {
100
+ throw new Error(`${name} contains null byte`);
101
+ }
102
+ // Check for dangerous path traversal patterns
103
+ if (path.includes('../') || path.includes('..\\') || path.includes('/..') || path.includes('\\..')) {
104
+ throw new Error(`${name} contains path traversal attempt: ${path}`);
105
+ }
106
+ // Check path depth to prevent deeply nested attacks
107
+ const depth = path.split('/').length - 1;
108
+ if (depth > MAX_PATH_DEPTH) {
109
+ throw new Error(`${name} exceeds maximum depth: ${depth} > ${MAX_PATH_DEPTH}`);
110
+ }
111
+ // Canonicalize the path (this also validates it exists and resolves symlinks)
112
+ try {
113
+ return await fs.realpath(path);
114
+ }
115
+ catch (error) {
116
+ throw new Error(`Invalid ${name}: ${error instanceof Error ? error.message : String(error)}`);
117
+ }
118
+ }
75
119
  // Create secure environment for CLI processes
76
120
  function createSecureEnvironment() {
77
121
  // Minimal environment whitelist
@@ -319,6 +363,30 @@ async function spawnAsync(command, args, options = {}) {
319
363
  }
320
364
  });
321
365
  }
366
+ const CLI_BUILDER_CONFIGS = {
367
+ claude: {
368
+ command: 'claude',
369
+ defaultArgs: ['--print'],
370
+ modelArgName: '--model',
371
+ mpcEnvCleanup: ['CLAUDE_MCP_CONFIG', 'MCP_ENABLED', 'CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'],
372
+ streamingArgs: (opts) => opts.progressToken ? ['--output-format', 'stream-json', '--verbose'] : []
373
+ },
374
+ codex: {
375
+ command: 'codex',
376
+ defaultArgs: ['exec'],
377
+ modelArgName: '--model',
378
+ jsonFlag: '--json',
379
+ mpcEnvCleanup: ['CODEX_MCP_CONFIG', 'MCP_ENABLED'],
380
+ promptWrapper: (sys, user) => `${sys}\n\n${user}\n\nExecute the complete analysis now in a single response without creating a plan first or waiting for input. Provide your full findings immediately.`
381
+ },
382
+ gemini: {
383
+ command: 'gemini',
384
+ defaultArgs: [],
385
+ modelArgName: '--model',
386
+ envExtras: { TERM: 'dumb', NO_COLOR: '1', CI: 'true' },
387
+ mpcEnvCleanup: ['GEMINI_MCP_CONFIG', 'MCP_ENABLED']
388
+ }
389
+ };
322
390
  export class CLIAgentOrchestrator {
323
391
  defaultTimeout = 1800000; // 30 minutes - complex codebases need time
324
392
  defaultWorkingDir = process.cwd();
@@ -366,50 +434,116 @@ export class CLIAgentOrchestrator {
366
434
  return chunk;
367
435
  }
368
436
  }
437
+ // Parse NDJSON with proper JSON boundary detection
438
+ // Handles JSON objects that contain embedded newlines without data loss
439
+ parseNDJSON(input) {
440
+ if (!input || !input.trim()) {
441
+ return [];
442
+ }
443
+ const results = [];
444
+ let depth = 0;
445
+ let inString = false;
446
+ let escape = false;
447
+ let start = 0;
448
+ for (let i = 0; i < input.length; i++) {
449
+ const char = input[i];
450
+ // Handle escape sequences
451
+ if (escape) {
452
+ escape = false;
453
+ continue;
454
+ }
455
+ if (char === '\\') {
456
+ escape = true;
457
+ continue;
458
+ }
459
+ // Track string boundaries
460
+ if (char === '"') {
461
+ inString = !inString;
462
+ continue;
463
+ }
464
+ // Only count braces/brackets outside of strings
465
+ if (inString)
466
+ continue;
467
+ // Track depth
468
+ if (char === '{' || char === '[') {
469
+ depth++;
470
+ }
471
+ else if (char === '}' || char === ']') {
472
+ depth--;
473
+ // When depth returns to 0, we've found a complete JSON object
474
+ if (depth === 0) {
475
+ const jsonStr = input.slice(start, i + 1).trim();
476
+ if (jsonStr) {
477
+ try {
478
+ const parsed = JSON.parse(jsonStr);
479
+ results.push(parsed);
480
+ }
481
+ catch (e) {
482
+ // Log unparseable segments (not silent)
483
+ logger.warn(`Failed to parse JSON segment at position ${start}-${i + 1}:`, {
484
+ preview: jsonStr.substring(0, 100),
485
+ error: e instanceof Error ? e.message : String(e)
486
+ });
487
+ }
488
+ }
489
+ // Move start pointer past this object and any whitespace
490
+ start = i + 1;
491
+ while (start < input.length && /\s/.test(input[start])) {
492
+ start++;
493
+ }
494
+ i = start - 1; // Will be incremented by loop
495
+ }
496
+ }
497
+ }
498
+ // Warn about incomplete JSON at end of input
499
+ if (start < input.length) {
500
+ const remaining = input.slice(start).trim();
501
+ if (remaining) {
502
+ logger.warn(`Incomplete JSON at end of input:`, {
503
+ preview: remaining.substring(0, 100)
504
+ });
505
+ }
506
+ }
507
+ return results;
508
+ }
369
509
  // Decode Claude's stream-json NDJSON output into plain text
370
510
  decodeClaudeStreamJson(ndjsonOutput) {
371
511
  if (!ndjsonOutput || !ndjsonOutput.trim()) {
372
512
  return '';
373
513
  }
374
514
  const textParts = [];
375
- const lines = ndjsonOutput.split('\n');
376
- for (const line of lines) {
377
- if (!line.trim())
515
+ const events = this.parseNDJSON(ndjsonOutput);
516
+ for (const event of events) {
517
+ if (typeof event !== 'object' || event === null)
378
518
  continue;
379
- try {
380
- const event = JSON.parse(line);
381
- // Handle different event types from Claude's stream-json format
382
- if (event.type === 'message' && event.message?.content) {
383
- // Full message event
384
- const content = event.message.content;
385
- if (Array.isArray(content)) {
386
- for (const item of content) {
387
- if (item.type === 'text' && item.text) {
388
- textParts.push(item.text);
389
- }
519
+ const typedEvent = event;
520
+ // Handle different event types from Claude's stream-json format
521
+ if (typedEvent.type === 'message' && typedEvent.message?.content) {
522
+ // Full message event
523
+ const content = typedEvent.message.content;
524
+ if (Array.isArray(content)) {
525
+ for (const item of content) {
526
+ if (item.type === 'text' && item.text) {
527
+ textParts.push(item.text);
390
528
  }
391
529
  }
392
530
  }
393
- else if (event.type === 'content_block_delta' && event.delta?.text) {
394
- // Incremental text delta
395
- textParts.push(event.delta.text);
396
- }
397
- else if (event.type === 'assistant' && event.message?.content) {
398
- // Assistant message format (same as parseClaudeStreamOutput)
399
- const content = event.message.content;
400
- if (Array.isArray(content)) {
401
- for (const item of content) {
402
- if (item.type === 'text' && item.text) {
403
- textParts.push(item.text);
404
- }
531
+ }
532
+ else if (typedEvent.type === 'content_block_delta' && typedEvent.delta?.text) {
533
+ // Incremental text delta
534
+ textParts.push(typedEvent.delta.text);
535
+ }
536
+ else if (typedEvent.type === 'assistant' && typedEvent.message?.content) {
537
+ // Assistant message format (same as parseClaudeStreamOutput)
538
+ const content = typedEvent.message.content;
539
+ if (Array.isArray(content)) {
540
+ for (const item of content) {
541
+ if (item.type === 'text' && item.text) {
542
+ textParts.push(item.text);
405
543
  }
406
544
  }
407
545
  }
408
546
  }
409
- catch {
410
- // Skip invalid JSON lines
411
- continue;
412
- }
413
547
  }
414
548
  return textParts.join('');
415
549
  }
@@ -420,32 +554,25 @@ export class CLIAgentOrchestrator {
420
554
  return '';
421
555
  }
422
556
  const agentMessages = [];
423
- const lines = jsonOutput.split('\n');
424
- logger.debug(`extractCodexAgentMessage: processing ${lines.length} lines`);
425
- for (const line of lines) {
426
- if (!line.trim())
557
+ const events = this.parseNDJSON(jsonOutput);
558
+ logger.debug(`extractCodexAgentMessage: processing ${events.length} JSON events`);
559
+ for (const event of events) {
560
+ if (typeof event !== 'object' || event === null)
427
561
  continue;
428
- try {
429
- const event = JSON.parse(line);
430
- logger.debug(`extractCodexAgentMessage: parsed event type=${event.type}, item.type=${event.item?.type}`);
431
- // Codex --json outputs events with structure: {"type":"item.completed","item":{...}}
432
- // Only extract agent_message type - this is the actual response
433
- if (event.type === 'item.completed' && event.item) {
434
- if (event.item.type === 'agent_message' && event.item.text) {
435
- // Agent's actual response text
436
- logger.info(`✅ extractCodexAgentMessage: found agent_message with ${event.item.text.length} chars`);
437
- agentMessages.push(event.item.text);
438
- }
439
- // Skip all other types:
440
- // - reasoning: internal thinking steps
441
- // - command_execution: file reads, bash commands
442
- // - error: will be in stderr
562
+ const typedEvent = event;
563
+ logger.debug(`extractCodexAgentMessage: parsed event type=${typedEvent.type}, item.type=${typedEvent.item?.type}`);
564
+ // Codex --json outputs events with structure: {"type":"item.completed","item":{...}}
565
+ // Only extract agent_message type - this is the actual response
566
+ if (typedEvent.type === 'item.completed' && typedEvent.item) {
567
+ if (typedEvent.item.type === 'agent_message' && typedEvent.item.text) {
568
+ // Agent's actual response text
569
+ logger.info(`✅ extractCodexAgentMessage: found agent_message with ${typedEvent.item.text.length} chars`);
570
+ agentMessages.push(typedEvent.item.text);
443
571
  }
444
- }
445
- catch (e) {
446
- // Skip non-JSON lines (config output, prompts, etc.)
447
- logger.debug(`extractCodexAgentMessage: failed to parse line: ${line.substring(0, 50)}`);
448
- continue;
572
+ // Skip all other types:
573
+ // - reasoning: internal thinking steps
574
+ // - command_execution: file reads, bash commands
575
+ // - error: will be in stderr
449
576
  }
450
577
  }
451
578
  const result = agentMessages.join('\n\n').trim();
@@ -499,6 +626,49 @@ export class CLIAgentOrchestrator {
499
626
  buffer.lastFlush = now;
500
627
  }
501
628
  }
629
+ buildCLICommand(cli, userPrompt, systemPrompt, options) {
630
+ const config = CLI_BUILDER_CONFIGS[cli];
631
+ // Build args
632
+ const args = [...config.defaultArgs];
633
+ const model = options.models?.[cli] || AVAILABLE_MODELS[cli].default;
634
+ if (model) {
635
+ args.push(config.modelArgName, model);
636
+ }
637
+ if (config.jsonFlag && process.env.CODEX_USE_JSON !== 'false') {
638
+ args.push(config.jsonFlag);
639
+ }
640
+ if (config.streamingArgs) {
641
+ args.push(...config.streamingArgs(options));
642
+ }
643
+ // Build prompt
644
+ const combinedPrompt = config.promptWrapper
645
+ ? config.promptWrapper(systemPrompt, userPrompt)
646
+ : `${systemPrompt}\n\n${userPrompt}`;
647
+ // Build secure env
648
+ const secureEnv = createSecureEnvironment();
649
+ // Add CLI-specific env extras
650
+ if (config.envExtras) {
651
+ Object.assign(secureEnv, config.envExtras);
652
+ }
653
+ // Add required API key
654
+ const apiKeyMap = {
655
+ claude: ['ANTHROPIC_API_KEY'],
656
+ codex: ['OPENAI_API_KEY'],
657
+ gemini: ['GOOGLE_API_KEY', 'GEMINI_API_KEY']
658
+ };
659
+ for (const key of apiKeyMap[cli]) {
660
+ if (process.env[key])
661
+ secureEnv[key] = process.env[key];
662
+ }
663
+ // Clean up MPC env vars that could cause deadlock
664
+ if (config.mpcEnvCleanup) {
665
+ for (const envVar of config.mpcEnvCleanup) {
666
+ delete secureEnv[envVar];
667
+ }
668
+ }
669
+ secureEnv.BRUTALIST_SUBPROCESS = '1';
670
+ return { command: config.command, args, input: combinedPrompt, env: secureEnv };
671
+ }
502
672
  async detectCLIContext() {
503
673
  // Return cached context if still valid
504
674
  if (this.cliContextCached && Date.now() - this.cliContextCacheTime < this.CLI_CACHE_TTL) {
@@ -694,103 +864,13 @@ export class CLIAgentOrchestrator {
694
864
  }
695
865
  }
696
866
  async executeClaudeCode(userPrompt, systemPromptSpec, options = {}) {
697
- return this._executeCLI('claude', userPrompt, systemPromptSpec, options, (userPrompt, systemPromptSpec, options) => {
698
- const combinedPrompt = `${systemPromptSpec}\n\n${userPrompt}`;
699
- const args = ['--print'];
700
- // Enable streaming for real-time progress if progress notifications are enabled
701
- if (options.progressToken) {
702
- args.push('--output-format', 'stream-json', '--verbose');
703
- }
704
- // Use provided model or let Claude use its default
705
- const model = options.models?.claude || AVAILABLE_MODELS.claude.default;
706
- if (model) {
707
- args.push('--model', model);
708
- }
709
- // Use stdin to avoid MAX_ARG_LENGTH limit (4096 chars)
710
- // Claude --print can read from stdin when no positional argument is provided
711
- // DEFENSIVE: Disable MCP and Claude Code integration to prevent stdio deadlock
712
- // When Claude CLI runs with MCP enabled or detects Claude Code context,
713
- // it tries to communicate over stdio which conflicts with our stdin/stdout usage
714
- const cleanEnv = { ...process.env };
715
- delete cleanEnv.CLAUDE_MCP_CONFIG;
716
- delete cleanEnv.MCP_ENABLED;
717
- delete cleanEnv.CLAUDECODE;
718
- delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
719
- return {
720
- command: 'claude',
721
- args,
722
- input: combinedPrompt,
723
- env: {
724
- ...cleanEnv,
725
- BRUTALIST_SUBPROCESS: '1' // Mark this as a brutalist-spawned subprocess
726
- }
727
- };
728
- });
867
+ return this._executeCLI('claude', userPrompt, systemPromptSpec, options, (user, sys, opts) => this.buildCLICommand('claude', user, sys, opts));
729
868
  }
730
869
  async executeCodex(userPrompt, systemPromptSpec, options = {}) {
731
- return this._executeCLI('codex', userPrompt, systemPromptSpec, { ...options }, (userPrompt, systemPromptSpec, options) => {
732
- // Instruct Codex to analyze immediately in one shot without waiting for approval
733
- const combinedPrompt = `${systemPromptSpec}\n\n${userPrompt}\n\nExecute the complete analysis now in a single response without creating a plan first or waiting for input. Provide your full findings immediately.`;
734
- const args = ['exec'];
735
- // Use provided model or let Codex use its default
736
- const model = options.models?.codex || AVAILABLE_MODELS.codex.default;
737
- if (model) {
738
- args.push('--model', model);
739
- }
740
- // OPTIONAL: Use --json flag to get structured output (can be disabled for compatibility)
741
- if (process.env.CODEX_USE_JSON !== 'false') {
742
- args.push('--json');
743
- }
744
- // DEFENSIVE: Disable MCP if Codex supports it (currently no known MCP support)
745
- // This prevents potential stdio deadlock if Codex adds MCP in the future
746
- // Note: Codex CLI doesn't currently have documented MCP config flags
747
- // Use stdin for the prompt instead of argv to avoid ARG_MAX limits
748
- // Create clean environment without MCP-related variables
749
- const cleanEnv = { ...process.env };
750
- delete cleanEnv.CODEX_MCP_CONFIG;
751
- delete cleanEnv.MCP_ENABLED;
752
- return {
753
- command: 'codex',
754
- args,
755
- input: combinedPrompt,
756
- env: {
757
- ...cleanEnv,
758
- BRUTALIST_SUBPROCESS: '1' // Mark this as a brutalist-spawned subprocess
759
- }
760
- };
761
- });
870
+ return this._executeCLI('codex', userPrompt, systemPromptSpec, options, (user, sys, opts) => this.buildCLICommand('codex', user, sys, opts));
762
871
  }
763
872
  async executeGemini(userPrompt, systemPromptSpec, options = {}) {
764
- return this._executeCLI('gemini', userPrompt, systemPromptSpec, { ...options }, (userPrompt, systemPromptSpec, options) => {
765
- const args = [];
766
- // Use provided model or let Gemini use its default
767
- const modelName = options.models?.gemini || AVAILABLE_MODELS.gemini.default;
768
- if (modelName) {
769
- args.push('--model', modelName);
770
- }
771
- // DEFENSIVE: Disable MCP if Gemini supports it (currently no known MCP support)
772
- // This prevents potential stdio deadlock if Gemini adds MCP in the future
773
- // Note: Gemini CLI doesn't currently have documented MCP config flags
774
- const combinedPrompt = `${systemPromptSpec}\n\n${userPrompt}`;
775
- // Use stdin to avoid MAX_ARG_LENGTH limit (4096 chars)
776
- // Gemini CLI can read from stdin instead of positional argument
777
- // Create clean environment without MCP-related variables
778
- const cleanEnv = { ...process.env };
779
- delete cleanEnv.GEMINI_MCP_CONFIG;
780
- delete cleanEnv.MCP_ENABLED;
781
- return {
782
- command: 'gemini',
783
- args: args,
784
- input: combinedPrompt, // Pass prompt via stdin instead of args
785
- env: {
786
- ...cleanEnv,
787
- TERM: 'dumb',
788
- NO_COLOR: '1',
789
- CI: 'true',
790
- BRUTALIST_SUBPROCESS: '1' // Mark this as a brutalist-spawned subprocess
791
- }
792
- };
793
- });
873
+ return this._executeCLI('gemini', userPrompt, systemPromptSpec, options, (user, sys, opts) => this.buildCLICommand('gemini', user, sys, opts));
794
874
  }
795
875
  async executeSingleCLI(cli, userPrompt, systemPromptSpec, options = {}) {
796
876
  // Wait for available slot to prevent resource exhaustion
@@ -859,7 +939,7 @@ export class CLIAgentOrchestrator {
859
939
  try {
860
940
  if (filesystemTools.includes(analysisType) && primaryContent && primaryContent.trim() !== '') {
861
941
  logger.debug(`Validating path: "${primaryContent}"`);
862
- validatePath(primaryContent, 'targetPath');
942
+ await asyncValidatePath(primaryContent, 'targetPath');
863
943
  }
864
944
  }
865
945
  catch (error) {
@@ -869,7 +949,7 @@ export class CLIAgentOrchestrator {
869
949
  // Validate workingDirectory if provided
870
950
  try {
871
951
  if (options.workingDirectory) {
872
- validatePath(options.workingDirectory, 'workingDirectory');
952
+ await asyncValidatePath(options.workingDirectory, 'workingDirectory');
873
953
  }
874
954
  }
875
955
  catch (error) {