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 { Permissions: func(context.Context, json.RawMessage, permission.Context) permission.Decision {
return permission.AskUser("call MCP tool " + full + "?") 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 var args any
if len(in) > 0 { if len(in) > 0 {
_ = json.Unmarshal(in, &args) _ = json.Unmarshal(in, &args)
@@ -315,7 +315,10 @@ func (c *Client) wrap(rt remoteTool) actool.CoreTool {
for _, blk := range res.Content { for _, blk := range res.Content {
text += blk.Text 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" "strings"
"time" "time"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/permission" "github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool" actool "github.com/Autumn-27/norma/tool"
"github.com/Autumn-27/artex/db"
) )
// 本文件实现自定义工具执行器(docs/自定义工具设计.md)。system=false 的 tools 行按 // 本文件实现自定义工具执行器(docs/自定义工具设计.md)。system=false 的 tools 行按
@@ -167,7 +167,7 @@ func (s *Server) pgTestCustomTool(w http.ResponseWriter, r *http.Request) {
case "script": case "script":
res, _ = s.runScriptTool(ctx, "test", req.Exec, params, tc) res, _ = s.runScriptTool(ctx, "test", req.Exec, params, tc)
case "http": case "http":
res, _ = s.runHTTPTool(ctx, req.Exec, params) res, _ = s.runHTTPTool(ctx, req.Exec, params, tc)
case "shell": case "shell":
writeErr(w, 400, "shell 类型工具是 bash 环境声明,无可执行内容") writeErr(w, 400, "shell 类型工具是 bash 环境声明,无可执行内容")
return return
@@ -279,7 +279,7 @@ func (s *Server) buildCustomTool(t *db.Tool) actool.CoreTool {
case "script": case "script":
return s.runScriptTool(ctx, key, execRaw, params, tc) return s.runScriptTool(ctx, key, execRaw, params, tc)
case "http": case "http":
return s.runHTTPTool(ctx, execRaw, params) return s.runHTTPTool(ctx, execRaw, params, tc)
default: default:
return actool.Errorf("未知自定义工具类型: " + kind), nil return actool.Errorf("未知自定义工具类型: " + kind), nil
} }
@@ -370,7 +370,7 @@ func (s *Server) runScriptTool(ctx context.Context, key string, execRaw json.Raw
if err != nil { if err != nil {
return actool.Errorf(err.Error()), 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 // 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:原生请求 + 代理 ---------- // ---------- 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 var spec httpExec
_ = json.Unmarshal(execRaw, &spec) _ = json.Unmarshal(execRaw, &spec)
method := strings.ToUpper(strings.TrimSpace(spec.Method)) 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)) respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
out := map[string]any{"status": resp.StatusCode, "body": string(respBody)} out := map[string]any{"status": resp.StatusCode, "body": string(respBody)}
b, _ := json.Marshal(out) 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 // 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 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}"}`, "body": `{"flag":"{flag}"}`,
}) })
res, err := (&Server{}).runHTTPTool(context.Background(), execRaw, 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 { if err != nil {
t.Fatalf("runHTTPTool: %v", err) t.Fatalf("runHTTPTool: %v", err)
} }
@@ -165,14 +165,23 @@ func TestDetectPython(t *testing.T) {
} }
} }
func TestClipOutput(t *testing.T) { // The http tool must truncate an oversized response through norma's Capture, the
long := strings.Repeat("x", 7000) // same valve every other tool uses — a session with no OutputDir falls back to a
got := clipOutput(long, nil) // head+tail cut, so a body far past the default cap comes back shortened.
if len(got) >= 7000 || !strings.Contains(got, "截断") { func TestHTTPToolTruncatesLargeBody(t *testing.T) {
t.Fatalf("clipOutput should truncate long output, got len %d", len(got)) 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" out := res.Flatten()
if clipOutput(short, nil) != short { if len(out) >= 40000 {
t.Fatal("clipOutput should pass short output through") t.Fatalf("oversized http body was not truncated: len=%d", len(out))
} }
} }