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)) }