Files
Blizzard e503965a83 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>
2026-06-24 16:47:15 +08:00

407 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,时区 %sUnix %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))
}