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