fix(tools): MCP/自定义工具输出统一走 Capture 截断,修复 mcp 截断不生效 (#176)

This commit is contained in:
Autumn-27 committed 2026-09-29 10:09:23 -04:00
1 parent 25b6018818
commit 71794729fc
3 files changed
+29 -29

No files matched your search

+5 -2
View File
@@ -292,7 +292,7 @@ func (c *Client) wrap(rt remoteTool) actool.CoreTool {
Permissions: func(context.Context, json.RawMessage, permission.Context) permission.Decision {
return permission.AskUser("call MCP tool " + full + "?")
},
Run: func(ctx context.Context, in json.RawMessage, _ *actool.ToolContext) (actool.Result, error) {
Run: func(ctx context.Context, in json.RawMessage, tc *actool.ToolContext) (actool.Result, error) {
var args any
if len(in) > 0 {
_ = json.Unmarshal(in, &args)
@@ -315,7 +315,10 @@ func (c *Client) wrap(rt remoteTool) actool.CoreTool {
for _, blk := range res.Content {
text += blk.Text
}
return actool.Result{Content: []llm.ContentBlock{llm.TextBlock(text)}, IsError: res.IsError}, nil
// 与内置/自定义工具一致:超长输出走 Capture——按会话 MaxOutputChars(默认
// 30000)截断,配了 ToolOutputDir 时全量溢写到磁盘只留 head + 指针,避免大
// MCP 结果整段灌爆上下文。
return actool.Result{Content: []llm.ContentBlock{llm.TextBlock(actool.Capture(tc, text))}, IsError: res.IsError}, nil
},
})
}
+6 -18
View File
@@ -17,9 +17,9 @@ import (
"strings"
"time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/artex/db"
)
// 本文件实现自定义工具执行器(docs/自定义工具设计.md)。system=false 的 tools 行按
@@ -167,7 +167,7 @@ func (s *Server) pgTestCustomTool(w http.ResponseWriter, r *http.Request) {
case "script":
res, _ = s.runScriptTool(ctx, "test", req.Exec, params, tc)
case "http":
res, _ = s.runHTTPTool(ctx, req.Exec, params)
res, _ = s.runHTTPTool(ctx, req.Exec, params, tc)
case "shell":
writeErr(w, 400, "shell 类型工具是 bash 环境声明,无可执行内容")
return
@@ -279,7 +279,7 @@ func (s *Server) buildCustomTool(t *db.Tool) actool.CoreTool {
case "script":
return s.runScriptTool(ctx, key, execRaw, params, tc)
case "http":
return s.runHTTPTool(ctx, execRaw, params)
return s.runHTTPTool(ctx, execRaw, params, tc)
default:
return actool.Errorf("未知自定义工具类型: " + kind), nil
}
@@ -370,7 +370,7 @@ func (s *Server) runScriptTool(ctx context.Context, key string, execRaw json.Raw
if err != nil {
return actool.Errorf(err.Error()), nil
}
return actool.Text(clipOutput(body, tc)), nil
return actool.Text(actool.Capture(tc, body)), nil
}
// execPython writes the code to a temp .py under workDir/.tools, runs it via interp
@@ -417,7 +417,7 @@ func execPython(ctx context.Context, interp, key, code string, params map[string
// ---------- http:原生请求 + 代理 ----------
func (s *Server) runHTTPTool(ctx context.Context, execRaw json.RawMessage, params map[string]any) (actool.Result, error) {
func (s *Server) runHTTPTool(ctx context.Context, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
var spec httpExec
_ = json.Unmarshal(execRaw, &spec)
method := strings.ToUpper(strings.TrimSpace(spec.Method))
@@ -453,7 +453,7 @@ func (s *Server) runHTTPTool(ctx context.Context, execRaw json.RawMessage, param
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
out := map[string]any{"status": resp.StatusCode, "body": string(respBody)}
b, _ := json.Marshal(out)
return actool.Text(clipOutput(string(b), nil)), nil
return actool.Text(actool.Capture(tc, string(b))), nil
}
// httpProxyTransport builds a Transport for the http tool's proxy config, or nil
@@ -529,15 +529,3 @@ func scalarStr(v any) (string, bool) {
return "", false
}
}
// clipOutput caps output to the session's MaxOutputChars (or a default).
func clipOutput(s string, tc *actool.ToolContext) string {
max := 6000
if tc != nil && tc.MaxOutputChars > 0 {
max = tc.MaxOutputChars
}
if len(s) <= max {
return s
}
return s[:max] + fmt.Sprintf("\n... [截断,共 %d 字节] ...", len(s))
}
+18 -9
View File
@@ -129,7 +129,7 @@ func TestRunHTTPTool(t *testing.T) {
"body": `{"flag":"{flag}"}`,
})
res, err := (&Server{}).runHTTPTool(context.Background(), execRaw,
map[string]any{"flag": "CTF{x}", "token": "sekret"})
map[string]any{"flag": "CTF{x}", "token": "sekret"}, nil)
if err != nil {
t.Fatalf("runHTTPTool: %v", err)
}
@@ -165,14 +165,23 @@ func TestDetectPython(t *testing.T) {
}
}
func TestClipOutput(t *testing.T) {
long := strings.Repeat("x", 7000)
got := clipOutput(long, nil)
if len(got) >= 7000 || !strings.Contains(got, "截断") {
t.Fatalf("clipOutput should truncate long output, got len %d", len(got))
// The http tool must truncate an oversized response through norma's Capture, the
// same valve every other tool uses — a session with no OutputDir falls back to a
// head+tail cut, so a body far past the default cap comes back shortened.
func TestHTTPToolTruncatesLargeBody(t *testing.T) {
big := strings.Repeat("x", 40000) // past Capture's 30000 default
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(big))
}))
defer srv.Close()
execRaw, _ := json.Marshal(map[string]any{"method": "GET", "url": srv.URL})
res, err := (&Server{}).runHTTPTool(context.Background(), execRaw, map[string]any{}, nil)
if err != nil {
t.Fatalf("runHTTPTool: %v", err)
}
short := "ok"
if clipOutput(short, nil) != short {
t.Fatal("clipOutput should pass short output through")
out := res.Flatten()
if len(out) >= 40000 {
t.Fatalf("oversized http body was not truncated: len=%d", len(out))
}
}