diff --git a/mcphttp/client.go b/mcphttp/client.go index ee31fd3..f56da34 100644 --- a/mcphttp/client.go +++ b/mcphttp/client.go @@ -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 }, }) } diff --git a/server/customtool.go b/server/customtool.go index 61423a5..4d9e30e 100644 --- a/server/customtool.go +++ b/server/customtool.go @@ -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)) -} diff --git a/server/customtool_test.go b/server/customtool_test.go index 90a1dbb..cead4fe 100644 --- a/server/customtool_test.go +++ b/server/customtool_test.go @@ -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)) } }