feat(tools): 新增 4 个 agent 工具 —— web_search / web_fetch / calculator / current_datetime
均在 mcp-go,注册即 agent 可见(dispatcher 经 list_tools 动态发现,零改调度代码): - web_search 联网搜索:Tavily(有 TAVILY_API_KEY 则用,干净 JSON)/ DuckDuckGo HTML(免 key 兜底)。 - web_fetch 网页抓取:取 URL → 去脚本样式 → 去标签 → 解实体 → 收敛空白,限 8000 rune; 复用 external_api 的 SSRF 防护(拒环回/内网/元数据 + 重定向校验)。 - calculator 计算器:自研调度场算法求值(+ - * / % ^ 括号、一元负号),杜绝任意代码执行。 - current_datetime 当前时间:含星期与时区(可传 tz)。 测试:单测覆盖 evalArith(含右结合/一元负号/错误用例)、htmlToText(剔脚本样式+解实体)、 ddgUnwrap。live 自主 agent 实测:calculator (123+456)*7→4053、current_datetime 周三、 web_fetch example.com→200、web_search DDG 返回真实结果,全部端到端通过。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 == "<nil>" {
|
||||
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)<script\b[^>]*>.*?</script\s*>|<style\b[^>]*>.*?</style\s*>|<head\b[^>]*>.*?</head\s*>|<noscript\b[^>]*>.*?</noscript\s*>`)
|
||||
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 == "<nil>" {
|
||||
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 == "<nil>" {
|
||||
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)<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
|
||||
reDDGSnippet = regexp.MustCompile(`(?is)<a[^>]+class="result__snippet"[^>]*>(.*?)</a>`)
|
||||
)
|
||||
|
||||
// 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))
|
||||
}
|
||||
Reference in New Issue
Block a user