From 609da7029f543a833912bed4d9c20c8d25852a43 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 5 May 2026 14:46:08 +0800 Subject: [PATCH] fix(mcp): expand JVM system properties in stdio args/env/cwd --- .../mate/llm/chatgpt/ChatGPTChatModel.java | 19 +++++++++- .../llm/chatgpt/ChatGPTResponsesClient.java | 36 +++++++++++++++---- .../tool/mcp/runtime/McpClientManager.java | 14 +++++--- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTChatModel.java b/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTChatModel.java index ca3a5fb5..3ed497ea 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTChatModel.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTChatModel.java @@ -5,6 +5,8 @@ import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.metadata.ChatGenerationMetadata; import org.springframework.ai.chat.metadata.ChatResponseMetadata; +import org.springframework.ai.chat.metadata.DefaultUsage; +import org.springframework.ai.chat.metadata.Usage; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; @@ -110,7 +112,22 @@ public class ChatGPTChatModel implements ChatModel { return null; } case "done" -> { - // 流结束,如果没有任何 tool call 产生过 done event 但有未完成的,忽略 + // Emit a final empty-content ChatResponse carrying the + // usage metadata so NodeStreamingChatHelper can record + // per-turn token counts. Without this the OAuth path's + // turns log 0/0/0 (the client only knows about Usage + // when it's attached to a ChatResponse.metadata). + if (event.inputTokens() != null || event.outputTokens() != null + || event.totalTokens() != null) { + int in = event.inputTokens() != null ? event.inputTokens() : 0; + int out = event.outputTokens() != null ? event.outputTokens() : 0; + int total = event.totalTokens() != null ? event.totalTokens() : (in + out); + Usage usage = new DefaultUsage(in, out, total); + Generation usageGen = new Generation(new AssistantMessage(""), + ChatGenerationMetadata.builder().finishReason("stop").build()); + return new ChatResponse(List.of(usageGen), + ChatResponseMetadata.builder().model(model).usage(usage).build()); + } return null; } default -> { return null; } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTResponsesClient.java b/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTResponsesClient.java index 7749cc7d..ac879f33 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTResponsesClient.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatgpt/ChatGPTResponsesClient.java @@ -37,12 +37,17 @@ public class ChatGPTResponsesClient { /** * 流式调用结果 — 包含文本增量和 tool call 事件 */ - public record StreamEvent(String type, String content, String toolCallId, String toolName, String toolArgsDelta) { - public static StreamEvent text(String delta) { return new StreamEvent("text", delta, null, null, null); } - public static StreamEvent toolCallStart(String callId, String name) { return new StreamEvent("tool_call_start", null, callId, name, null); } - public static StreamEvent toolCallArgsDelta(String callId, String delta) { return new StreamEvent("tool_call_args_delta", null, callId, null, delta); } - public static StreamEvent toolCallDone(String callId, String args) { return new StreamEvent("tool_call_done", null, callId, null, args); } - public static StreamEvent done() { return new StreamEvent("done", null, null, null, null); } + public record StreamEvent(String type, String content, String toolCallId, String toolName, String toolArgsDelta, + Integer inputTokens, Integer outputTokens, Integer totalTokens) { + public static StreamEvent text(String delta) { return new StreamEvent("text", delta, null, null, null, null, null, null); } + public static StreamEvent toolCallStart(String callId, String name) { return new StreamEvent("tool_call_start", null, callId, name, null, null, null, null); } + public static StreamEvent toolCallArgsDelta(String callId, String delta) { return new StreamEvent("tool_call_args_delta", null, callId, null, delta, null, null, null); } + public static StreamEvent toolCallDone(String callId, String args) { return new StreamEvent("tool_call_done", null, callId, null, args, null, null, null); } + public static StreamEvent done() { return new StreamEvent("done", null, null, null, null, null, null, null); } + /** Done event carrying token usage extracted from {@code response.completed.response.usage}. */ + public static StreamEvent done(Integer in, Integer out, Integer total) { + return new StreamEvent("done", null, null, null, null, in, out, total); + } } /** @@ -281,8 +286,25 @@ public class ChatGPTResponsesClient { return StreamEvent.toolCallDone(callId, args); } - // 完成/结束 + // 完成/结束 — extract usage from response.completed so the agent + // pipeline can record per-turn token counts (otherwise OAuth turns + // get logged as 0/0/0). Field shape mirrors the upstream client: + // input_tokens → prompt, output_tokens → completion, total_tokens + // falls back to (input + output) when the API omits it. if (type.startsWith("response.completed") || type.startsWith("response.done")) { + JsonNode usage = node.path("response").path("usage"); + if (!usage.isMissingNode() && !usage.isNull()) { + Integer in = usage.has("input_tokens") ? usage.get("input_tokens").asInt() : null; + Integer out = usage.has("output_tokens") ? usage.get("output_tokens").asInt() : null; + Integer total = usage.has("total_tokens") + ? usage.get("total_tokens").asInt() + : (in != null && out != null ? in + out : null); + if (in != null || out != null || total != null) { + log.debug("[ChatGPT] Usage in response.completed: input={}, output={}, total={}", + in, out, total); + return StreamEvent.done(in, out, total); + } + } return StreamEvent.done(); } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java index 88dd7a71..542b1cd5 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/mcp/runtime/McpClientManager.java @@ -269,6 +269,7 @@ public class McpClientManager { // Args if (server.getArgsJson() != null && !server.getArgsJson().isBlank()) { List args = JSONUtil.toList(server.getArgsJson(), String.class); + args = args.stream().map(McpClientManager::expandEnvVars).toList(); builder.args(args); } @@ -376,17 +377,22 @@ public class McpClientManager { } /** - * 展开环境变量引用,如 ${ENV_VAR} 或 $ENV_VAR + * 展开系统属性和环境变量引用,如 ${user.home}、${ENV_VAR} 或 $ENV_VAR *

- * 先处理 ${VAR}(精确匹配),再用正则处理 $VAR(word boundary), - * 避免 $PATH 误替换 $PATH_HOME 的问题。 + * 先处理 ${VAR}(精确匹配,JVM 系统属性优先于环境变量, + * 这样 ${user.home} 等跨平台占位符在 Windows 上也能解析), + * 再用正则处理 $VAR(word boundary),避免 $PATH 误替换 $PATH_HOME 的问题。 */ private static String expandEnvVars(String value) { if (value == null || !value.contains("$")) { return value; } String result = value; - // Phase 1: 精确匹配 ${VAR} 模式(不会误替换) + // Phase 1a: ${VAR} 优先匹配 JVM system property(如 ${user.home}、${java.io.tmpdir}) + for (String key : System.getProperties().stringPropertyNames()) { + result = result.replace("${" + key + "}", System.getProperty(key)); + } + // Phase 1b: ${VAR} 回退匹配 OS 环境变量 for (Map.Entry env : System.getenv().entrySet()) { result = result.replace("${" + env.getKey() + "}", env.getValue()); }