diff --git a/sundynix-mcp-go/internal/mcp/gateway.go b/sundynix-mcp-go/internal/mcp/gateway.go index 4e758a6..b696a58 100644 --- a/sundynix-mcp-go/internal/mcp/gateway.go +++ b/sundynix-mcp-go/internal/mcp/gateway.go @@ -81,7 +81,7 @@ func (g *Gateway) buildRegistry() map[string]toolDef { // —— 暴露给自主 agent 的工具(带参数 schema / 注入声明)—— "wiki_search": { cn: "知识检索", desc: "检索知识库,返回与查询最相关的资料片段。需要外部知识/事实依据时调用。", - agent: true, + agent: true, params: []paramSpec{{Name: "q", Type: "string", Desc: "检索查询语句", Required: true}}, inject: []string{"kb"}, handler: g.wikiSearch, }, @@ -102,6 +102,33 @@ func (g *Gateway) buildRegistry() map[string]toolDef { cn: "历史召回", desc: "取当前会话最近多轮对话,用于理解上下文。", agent: true, inject: []string{"session_id"}, handler: g.historyGet, }, + "web_search": { + cn: "联网搜索", desc: "联网搜索,返回最新网页结果(标题/链接/摘要)。需要实时/最新信息或外部事实时调用。", + agent: true, + params: []paramSpec{ + {Name: "q", Type: "string", Desc: "搜索关键词", Required: true}, + {Name: "topK", Type: "integer", Desc: "返回结果条数(默认 5,最多 10)"}, + }, + handler: g.webSearch, + }, + "web_fetch": { + cn: "网页抓取", desc: "抓取一个网页 URL 并提取正文文本。需要读取某个链接的内容时调用。", + agent: true, + params: []paramSpec{{Name: "url", Type: "string", Desc: "要抓取的网页 URL", Required: true}}, + handler: g.webFetch, + }, + "calculator": { + cn: "计算器", desc: "精确计算数学表达式(+ - * / % ^ 与括号)。涉及算术/数值计算时调用,不要心算。", + agent: true, + params: []paramSpec{{Name: "expr", Type: "string", Desc: "数学表达式,如 (3+4)*2^3", Required: true}}, + handler: g.calculator, + }, + "current_datetime": { + cn: "当前时间", desc: "获取当前日期与时间(含星期)。需要“现在/今天几号/星期几”等时间信息时调用。", + agent: true, + params: []paramSpec{{Name: "tz", Type: "string", Desc: "可选时区,如 Asia/Shanghai;缺省服务器本地时区"}}, + handler: g.currentDatetime, + }, // —— 仅内部/流水线/管理用,不暴露给自主 agent —— "kb_ingest": {cn: "知识入库", desc: "文本切块 → 向量化 → 写入 Milvus / Bleve", handler: g.kbIngest}, @@ -147,10 +174,10 @@ func (g *Gateway) listTools() *contract.ToolResult { Name string `json:"name"` CN string `json:"cn"` Desc string `json:"desc"` - Agent bool `json:"agent_exposed"` // 是否给自主 agent - AgentName string `json:"agent_name,omitempty"`// 模型可见名(空=name) - Params []paramSpec `json:"params,omitempty"` // 模型可填参数 - Inject []string `json:"inject,omitempty"` // 服务端注入参数(不暴露给模型) + Agent bool `json:"agent_exposed"` // 是否给自主 agent + AgentName string `json:"agent_name,omitempty"` // 模型可见名(空=name) + Params []paramSpec `json:"params,omitempty"` // 模型可填参数 + Inject []string `json:"inject,omitempty"` // 服务端注入参数(不暴露给模型) } out := make([]info, 0, len(g.tools)) for name, td := range g.tools { diff --git a/sundynix-mcp-go/internal/mcp/tools_extra.go b/sundynix-mcp-go/internal/mcp/tools_extra.go new file mode 100644 index 0000000..4693ff6 --- /dev/null +++ b/sundynix-mcp-go/internal/mcp/tools_extra.go @@ -0,0 +1,406 @@ +package mcp + +import ( + "context" + "fmt" + "html" + "io" + "math" + "net/http" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "time" + + "github.com/sundynix/sundynix-shared/contract" +) + +// ===== current_datetime:当前日期时间(LLM 不知道"现在")===== + +var weekdaysCN = []string{"周日", "周一", "周二", "周三", "周四", "周五", "周六"} + +func (g *Gateway) currentDatetime(_ context.Context, call *contract.ToolCall) *contract.ToolResult { + loc := time.Local + if tz, _ := call.Args["tz"].(string); strings.TrimSpace(tz) != "" { + if l, err := time.LoadLocation(strings.TrimSpace(tz)); err == nil { + loc = l + } + } + now := time.Now().In(loc) + out := fmt.Sprintf("%s %s(%s,时区 %s,Unix %d)", + now.Format("2006-01-02"), now.Format("15:04:05"), + weekdaysCN[int(now.Weekday())], now.Format("MST-07:00"), now.Unix()) + return &contract.ToolResult{OK: true, Content: out} +} + +// ===== calculator:安全表达式求值(+ - * / % ^ 括号、一元负号)===== + +func (g *Gateway) calculator(_ context.Context, call *contract.ToolCall) *contract.ToolResult { + expr := strings.TrimSpace(fmt.Sprint(call.Args["expr"])) + if expr == "" || expr == "" { + return &contract.ToolResult{OK: false, Error: "calculator: expr 必填"} + } + v, err := evalArith(expr) + if err != nil { + return &contract.ToolResult{OK: false, Error: "calculator: " + err.Error()} + } + return &contract.ToolResult{OK: true, Content: strconv.FormatFloat(v, 'g', -1, 64)} +} + +// evalArith 用调度场算法把中缀表达式转 RPN 再求值。仅支持数值与 + - * / % ^ ( ),杜绝任意代码执行。 +func evalArith(s string) (float64, error) { + toks, err := tokenizeArith(s) + if err != nil { + return 0, err + } + prec := map[string]int{"+": 1, "-": 1, "*": 2, "/": 2, "%": 2, "^": 3, "u-": 4} + rightAssoc := map[string]bool{"^": true, "u-": true} + var output, ops []string + for i, t := range toks { + switch { + case isNumber(t): + output = append(output, t) + case t == "(": + ops = append(ops, t) + case t == ")": + for len(ops) > 0 && ops[len(ops)-1] != "(" { + output = append(output, ops[len(ops)-1]) + ops = ops[:len(ops)-1] + } + if len(ops) == 0 { + return 0, fmt.Errorf("括号不匹配") + } + ops = ops[:len(ops)-1] // 弹出 "(" + default: // 运算符 + op := t + // 一元负号:在表达式开头或运算符/左括号之后的 "-"。 + if op == "-" && (i == 0 || isOperator(toks[i-1]) || toks[i-1] == "(") { + op = "u-" + } + for len(ops) > 0 { + top := ops[len(ops)-1] + if top == "(" { + break + } + if prec[top] > prec[op] || (prec[top] == prec[op] && !rightAssoc[op]) { + output = append(output, top) + ops = ops[:len(ops)-1] + } else { + break + } + } + ops = append(ops, op) + } + } + for len(ops) > 0 { + if ops[len(ops)-1] == "(" { + return 0, fmt.Errorf("括号不匹配") + } + output = append(output, ops[len(ops)-1]) + ops = ops[:len(ops)-1] + } + return evalRPN(output) +} + +func evalRPN(rpn []string) (float64, error) { + var st []float64 + pop := func() (float64, error) { + if len(st) == 0 { + return 0, fmt.Errorf("表达式非法") + } + v := st[len(st)-1] + st = st[:len(st)-1] + return v, nil + } + for _, t := range rpn { + if isNumber(t) { + f, _ := strconv.ParseFloat(t, 64) + st = append(st, f) + continue + } + if t == "u-" { + a, err := pop() + if err != nil { + return 0, err + } + st = append(st, -a) + continue + } + b, err := pop() + if err != nil { + return 0, err + } + a, err := pop() + if err != nil { + return 0, err + } + switch t { + case "+": + st = append(st, a+b) + case "-": + st = append(st, a-b) + case "*": + st = append(st, a*b) + case "/": + if b == 0 { + return 0, fmt.Errorf("除以零") + } + st = append(st, a/b) + case "%": + st = append(st, math.Mod(a, b)) + case "^": + st = append(st, math.Pow(a, b)) + default: + return 0, fmt.Errorf("未知运算符 %q", t) + } + } + if len(st) != 1 { + return 0, fmt.Errorf("表达式非法") + } + return st[0], nil +} + +func tokenizeArith(s string) ([]string, error) { + var toks []string + r := []rune(s) + for i := 0; i < len(r); { + c := r[i] + switch { + case c == ' ' || c == '\t': + i++ + case strings.ContainsRune("+-*/%^()", c): + toks = append(toks, string(c)) + i++ + case (c >= '0' && c <= '9') || c == '.': + j := i + for j < len(r) && ((r[j] >= '0' && r[j] <= '9') || r[j] == '.' || r[j] == 'e' || r[j] == 'E' || + ((r[j] == '+' || r[j] == '-') && j > i && (r[j-1] == 'e' || r[j-1] == 'E'))) { + j++ + } + num := string(r[i:j]) + if _, err := strconv.ParseFloat(num, 64); err != nil { + return nil, fmt.Errorf("非法数字 %q", num) + } + toks = append(toks, num) + i = j + default: + return nil, fmt.Errorf("非法字符 %q(仅支持数字与 + - * / %% ^ ( ))", string(c)) + } + } + if len(toks) == 0 { + return nil, fmt.Errorf("空表达式") + } + return toks, nil +} + +func isNumber(t string) bool { _, err := strconv.ParseFloat(t, 64); return err == nil } +func isOperator(t string) bool { + return t == "+" || t == "-" || t == "*" || t == "/" || t == "%" || t == "^" +} + +// ===== web_fetch:抓取网页并提取正文文本 ===== + +var ( + // RE2 不支持反向引用,逐标签枚举其配对闭合。 + reScriptStyle = regexp.MustCompile(`(?is)]*>.*?|]*>.*?|]*>.*?|]*>.*?`) + reTag = regexp.MustCompile(`(?s)<[^>]+>`) + reWS = regexp.MustCompile(`[ \t\x{00a0}]+`) + reBlankLines = regexp.MustCompile(`\n\s*\n\s*\n+`) +) + +const webFetchMaxText = 8000 // 提取正文上限(rune),避免塞爆上下文 + +func (g *Gateway) webFetch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { + raw := strings.TrimSpace(fmt.Sprint(call.Args["url"])) + if raw == "" || raw == "" { + return &contract.ToolResult{OK: false, Error: "web_fetch: url 必填"} + } + if reason, ok := validateExternalURL(raw, extAllowlist()); !ok { + return &contract.ToolResult{OK: false, Error: "web_fetch: URL 被拦截 —— " + reason} + } + body, status, err := httpGet(ctx, raw) + if err != nil { + return &contract.ToolResult{OK: false, Error: "web_fetch: " + err.Error()} + } + text := htmlToText(body) + if rs := []rune(text); len(rs) > webFetchMaxText { + text = string(rs[:webFetchMaxText]) + "\n…(已截断)" + } + return &contract.ToolResult{OK: true, Content: fmt.Sprintf("URL: %s (HTTP %d)\n\n%s", raw, status, text)} +} + +// htmlToText 把 HTML 粗提取为可读文本:去脚本/样式 → 去标签 → 解实体 → 收敛空白。 +func htmlToText(h string) string { + h = reScriptStyle.ReplaceAllString(h, " ") + h = regexp.MustCompile(`(?i)<\s*(br|/p|/div|/li|/h[1-6]|/tr)\s*/?>`).ReplaceAllString(h, "\n") + h = reTag.ReplaceAllString(h, "") + h = html.UnescapeString(h) + h = reWS.ReplaceAllString(h, " ") + h = reBlankLines.ReplaceAllString(h, "\n\n") + return strings.TrimSpace(h) +} + +// ===== web_search:联网搜索(Tavily 有 key 则用,否则 DuckDuckGo HTML 兜底)===== + +const webSearchDefaultTopK = 5 + +func (g *Gateway) webSearch(ctx context.Context, call *contract.ToolCall) *contract.ToolResult { + q := strings.TrimSpace(fmt.Sprint(call.Args["q"])) + if q == "" || q == "" { + return &contract.ToolResult{OK: false, Error: "web_search: q 必填"} + } + topK := webSearchDefaultTopK + if n, ok := toInt(call.Args["topK"]); ok && n > 0 && n <= 10 { + topK = n + } + if key := strings.TrimSpace(os.Getenv("TAVILY_API_KEY")); key != "" { + if out, err := tavilySearch(ctx, key, q, topK); err == nil { + return &contract.ToolResult{OK: true, Content: out} + } + // Tavily 失败 → 落 DDG 兜底 + } + out, err := ddgSearch(ctx, q, topK) + if err != nil { + return &contract.ToolResult{OK: false, Error: "web_search: " + err.Error()} + } + return &contract.ToolResult{OK: true, Content: out} +} + +var ( + reDDGLink = regexp.MustCompile(`(?is)]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)`) + reDDGSnippet = regexp.MustCompile(`(?is)]+class="result__snippet"[^>]*>(.*?)`) +) + +// ddgSearch 抓 DuckDuckGo HTML 版结果(免 key)。SERP 结构变动时尽力解析,解析不到则返回提示。 +func ddgSearch(ctx context.Context, q string, topK int) (string, error) { + u := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(q) + body, _, err := httpGet(ctx, u) + if err != nil { + return "", err + } + links := reDDGLink.FindAllStringSubmatch(body, -1) + snips := reDDGSnippet.FindAllStringSubmatch(body, -1) + if len(links) == 0 { + return "", fmt.Errorf("未解析到结果(DDG 可能限流或改版)") + } + var b strings.Builder + for i := 0; i < len(links) && i < topK; i++ { + title := strings.TrimSpace(htmlToText(links[i][2])) + href := ddgUnwrap(links[i][1]) + snippet := "" + if i < len(snips) { + snippet = strings.TrimSpace(htmlToText(snips[i][1])) + } + fmt.Fprintf(&b, "%d. %s\n %s\n %s\n", i+1, title, href, snippet) + } + return strings.TrimSpace(b.String()), nil +} + +// ddgUnwrap 还原 DDG 的跳转链接 /l/?uddg=<编码真实 url>。 +func ddgUnwrap(href string) string { + if i := strings.Index(href, "uddg="); i >= 0 { + raw := href[i+len("uddg="):] + if amp := strings.IndexByte(raw, '&'); amp >= 0 { + raw = raw[:amp] + } + if dec, err := url.QueryUnescape(raw); err == nil { + return dec + } + } + if strings.HasPrefix(href, "//") { + return "https:" + href + } + return href +} + +// tavilySearch 走 Tavily Search API(需 TAVILY_API_KEY)—— 返回干净的标题/URL/摘要。 +func tavilySearch(ctx context.Context, key, q string, topK int) (string, error) { + payload := fmt.Sprintf(`{"api_key":%q,"query":%q,"max_results":%d}`, key, q, topK) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.tavily.com/search", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + resp, err := (&http.Client{Timeout: extTimeout}).Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return "", fmt.Errorf("tavily HTTP %d", resp.StatusCode) + } + data, _ := io.ReadAll(io.LimitReader(resp.Body, extMaxBytes)) + // 轻量提取(不引 JSON 结构体):用正则挑 title/url/content。 + reItem := regexp.MustCompile(`(?is)"title"\s*:\s*"(.*?)".*?"url"\s*:\s*"(.*?)".*?"content"\s*:\s*"(.*?)"`) + items := reItem.FindAllStringSubmatch(string(data), -1) + if len(items) == 0 { + return "", fmt.Errorf("tavily 无结果") + } + var b strings.Builder + for i, m := range items { + if i >= topK { + break + } + fmt.Fprintf(&b, "%d. %s\n %s\n %s\n", i+1, jsonUnesc(m[1]), jsonUnesc(m[2]), truncateRunes(jsonUnesc(m[3]), 240)) + } + return strings.TrimSpace(b.String()), nil +} + +// ===== 公共小工具 ===== + +func httpGet(ctx context.Context, raw string) (body string, status int, err error) { + allow := extAllowlist() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) + if err != nil { + return "", 0, err + } + // 带个常见 UA,部分站点对空 UA 返回 403。 + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; sundynix-agentix/1.0)") + client := &http.Client{ + Timeout: extTimeout, + CheckRedirect: func(r *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return fmt.Errorf("重定向过多") + } + if reason, ok := validateExternalURL(r.URL.String(), allow); !ok { + return fmt.Errorf("重定向被拦截:%s", reason) + } + return nil + }, + } + resp, err := client.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, extMaxBytes)) + return string(data), resp.StatusCode, nil +} + +func toInt(v any) (int, bool) { + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + case string: + if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil { + return i, true + } + } + return 0, false +} + +// truncateRunes 按 rune 截断(中文安全),超出加省略号。 +func truncateRunes(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + +// jsonUnesc 还原 JSON 字符串里的常见转义(够用即可,不做完整解码)。 +func jsonUnesc(s string) string { + r := strings.NewReplacer(`\"`, `"`, `\\`, `\`, `\n`, " ", `\t`, " ", `\/`, "/") + return strings.TrimSpace(r.Replace(s)) +} diff --git a/sundynix-mcp-go/internal/mcp/tools_extra_test.go b/sundynix-mcp-go/internal/mcp/tools_extra_test.go new file mode 100644 index 0000000..96ffd04 --- /dev/null +++ b/sundynix-mcp-go/internal/mcp/tools_extra_test.go @@ -0,0 +1,58 @@ +package mcp + +import ( + "math" + "strings" + "testing" +) + +func TestEvalArith(t *testing.T) { + cases := map[string]float64{ + "1+2*3": 7, + "(1+2)*3": 9, + "2^3^2": 512, // 右结合 + "-3+5": 2, + "10/4": 2.5, + "10%3": 1, + "2*(3+4)-5": 9, + "-(2+3)*2": -10, + "3.5e1+0.5": 35.5, + } + for in, want := range cases { + got, err := evalArith(in) + if err != nil { + t.Errorf("evalArith(%q) 报错: %v", in, err) + continue + } + if math.Abs(got-want) > 1e-9 { + t.Errorf("evalArith(%q)=%v want %v", in, got, want) + } + } +} + +func TestEvalArithErrors(t *testing.T) { + for _, in := range []string{"1/0", "1+", "(1+2", "1+2)", "rm -rf /", "import os", ""} { + if _, err := evalArith(in); err == nil { + t.Errorf("evalArith(%q) 应报错但没有", in) + } + } +} + +func TestHTMLToText(t *testing.T) { + in := `x +

标题

第一段&符号

第二段
` + got := htmlToText(in) + if strings.Contains(got, "alert") || strings.Contains(got, ".a{}") { + t.Fatalf("脚本/样式未剔除: %q", got) + } + if !strings.Contains(got, "标题") || !strings.Contains(got, "第一段&符号") || !strings.Contains(got, "第二段") { + t.Fatalf("正文/实体提取不正确: %q", got) + } +} + +func TestDDGUnwrap(t *testing.T) { + got := ddgUnwrap("//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa&rut=x") + if got != "https://example.com/a" { + t.Fatalf("ddgUnwrap 解码错误: %q", got) + } +}