|
40 | 40 |
|
41 | 41 | import java.io.*; |
42 | 42 | import java.nio.charset.StandardCharsets; |
| 43 | +import java.nio.file.Files; |
43 | 44 | import java.nio.file.Path; |
44 | 45 | import java.time.Duration; |
45 | 46 | import java.util.ArrayList; |
|
54 | 55 | import java.util.function.Consumer; |
55 | 56 |
|
56 | 57 | /** |
57 | | - * Streaming transport for Claude CLI communication. Manages the subprocess lifecycle |
58 | | - * and handles JSON message streaming via stdin/stdout. |
| 58 | + * Streaming transport for Claude CLI communication. Manages the subprocess lifecycle and |
| 59 | + * handles JSON message streaming via stdin/stdout. |
59 | 60 | * |
60 | 61 | * <p> |
61 | 62 | * Key features: |
@@ -126,6 +127,9 @@ public class StreamingTransport implements AutoCloseable { |
126 | 127 | /** Flag for clean shutdown - volatile for visibility across threads (MCP pattern) */ |
127 | 128 | private volatile boolean isClosing = false; |
128 | 129 |
|
| 130 | + /** Temp file for MCP config — written before session start, deleted on close. */ |
| 131 | + private volatile Path mcpConfigFile; |
| 132 | + |
129 | 133 | /** Stderr handler for the current session (may be null if using default logging). */ |
130 | 134 | private volatile StderrHandler currentStderrHandler; |
131 | 135 |
|
@@ -215,8 +219,10 @@ public StreamingTransport(Path workingDirectory, Duration defaultTimeout, String |
215 | 219 | .fromExecutorService(Executors.newSingleThreadExecutor(r -> new Thread(r, "claude-error")), "error"); |
216 | 220 |
|
217 | 221 | // Initialize sinks with backpressure |
218 | | - // Use replay() to buffer messages for late subscribers in multi-turn conversations |
219 | | - // This ensures messages aren't lost between turns when there's no active subscriber |
| 222 | + // Use replay() to buffer messages for late subscribers in multi-turn |
| 223 | + // conversations |
| 224 | + // This ensures messages aren't lost between turns when there's no active |
| 225 | + // subscriber |
220 | 226 | this.inboundSink = Sinks.many().replay().all(); |
221 | 227 | this.outboundSink = Sinks.many().unicast().onBackpressureBuffer(); |
222 | 228 | this.serverInfoSink = Sinks.one(); |
@@ -405,7 +411,8 @@ List<String> buildStreamingCommand(CLIOptions options) { |
405 | 411 | command.add("stream-json"); |
406 | 412 | // NOTE: --permission-prompt-tool is NOT added unconditionally |
407 | 413 | // This matches Python SDK behavior where it's only added if explicitly configured |
408 | | - // Adding it unconditionally may affect how --allowedTools restrictions are enforced |
| 414 | + // Adding it unconditionally may affect how --allowedTools restrictions are |
| 415 | + // enforced |
409 | 416 | command.add("--verbose"); |
410 | 417 |
|
411 | 418 | // Standard options |
@@ -503,18 +510,21 @@ List<String> buildStreamingCommand(CLIOptions options) { |
503 | 510 | } |
504 | 511 | } |
505 | 512 |
|
506 | | - // Add MCP server configuration |
| 513 | + // Add MCP server configuration via temp file (avoids shell escaping issues) |
507 | 514 | if (options.getMcpServers() != null && !options.getMcpServers().isEmpty()) { |
508 | 515 | try { |
509 | 516 | Map<String, Object> serversForCli = buildMcpConfigForCli(options.getMcpServers()); |
510 | 517 | if (!serversForCli.isEmpty()) { |
511 | 518 | String mcpConfigJson = objectMapper.writeValueAsString(Map.of("mcpServers", serversForCli)); |
| 519 | + this.mcpConfigFile = Files.createTempFile("claude-mcp-", ".json"); |
| 520 | + Files.writeString(this.mcpConfigFile, mcpConfigJson); |
512 | 521 | command.add("--mcp-config"); |
513 | | - command.add(mcpConfigJson); |
| 522 | + command.add(this.mcpConfigFile.toString()); |
| 523 | + logger.debug("Wrote MCP config to temp file: {}", this.mcpConfigFile); |
514 | 524 | } |
515 | 525 | } |
516 | | - catch (JsonProcessingException e) { |
517 | | - logger.warn("Failed to serialize MCP config, skipping --mcp-config flag", e); |
| 526 | + catch (IOException e) { |
| 527 | + logger.warn("Failed to write MCP config file, skipping --mcp-config flag", e); |
518 | 528 | } |
519 | 529 | } |
520 | 530 |
|
@@ -567,7 +577,8 @@ List<String> buildStreamingCommand(CLIOptions options) { |
567 | 577 | } |
568 | 578 |
|
569 | 579 | // Permission prompt tool - matches Python SDK auto-detection pattern |
570 | | - // Python SDK (client.py lines 68-69): Automatically sets permission_prompt_tool_name="stdio" |
| 580 | + // Python SDK (client.py lines 68-69): Automatically sets |
| 581 | + // permission_prompt_tool_name="stdio" |
571 | 582 | // when a can_use_tool callback is configured |
572 | 583 | String permissionPromptTool = options.getPermissionPromptToolName(); |
573 | 584 | if (permissionPromptTool == null && options.getToolPermissionCallback() != null) { |
@@ -785,9 +796,11 @@ else if (parsed.isControlResponse()) { |
785 | 796 | // Log why loop ended |
786 | 797 | if (isClosing) { |
787 | 798 | logger.debug("Message processing loop ended: isClosing=true"); |
788 | | - } else if (process != null && !process.isAlive()) { |
| 799 | + } |
| 800 | + else if (process != null && !process.isAlive()) { |
789 | 801 | logger.debug("Message processing loop ended: process exited with code {}", process.exitValue()); |
790 | | - } else { |
| 802 | + } |
| 803 | + else { |
791 | 804 | logger.debug("Message processing loop ended: stdout closed"); |
792 | 805 | } |
793 | 806 | } |
@@ -1154,6 +1167,18 @@ public void close() { |
1154 | 1167 | } |
1155 | 1168 | } |
1156 | 1169 |
|
| 1170 | + // Clean up MCP config temp file |
| 1171 | + if (mcpConfigFile != null) { |
| 1172 | + try { |
| 1173 | + Files.deleteIfExists(mcpConfigFile); |
| 1174 | + logger.debug("Deleted MCP config temp file: {}", mcpConfigFile); |
| 1175 | + } |
| 1176 | + catch (IOException e) { |
| 1177 | + logger.debug("Failed to delete MCP config temp file: {}", mcpConfigFile, e); |
| 1178 | + } |
| 1179 | + mcpConfigFile = null; |
| 1180 | + } |
| 1181 | + |
1157 | 1182 | // Shutdown schedulers |
1158 | 1183 | inboundScheduler.dispose(); |
1159 | 1184 | outboundScheduler.dispose(); |
|
0 commit comments