初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
package mcp
import (
"encoding/json"
"log/slog"
"net/http"
"strings"
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
)
// registerAdminRoutes 注册管理端点到 mux。
func (h *Handler) registerAdminRoutes(mux *http.ServeMux) {
mux.HandleFunc("/admin/provider/", h.handleProviderAdmin)
mux.HandleFunc("/admin/cache/clear", h.handleCacheClear)
mux.HandleFunc("/admin/keys", h.handleKeys)
}
// handleProviderAdmin 管理 provider 启用/禁用。
// PUT /admin/provider/{name}/enable
// PUT /admin/provider/{name}/disable
func (h *Handler) handleProviderAdmin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 解析路径:/admin/provider/{name}/{action}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/admin/provider/"), "/")
if len(parts) != 2 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "路径格式: /admin/provider/{name}/{action}"})
return
}
name := models.ProviderKind(parts[0])
action := parts[1]
clientIP := requestIP(r)
if !h.balancer.HasProvider(name) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "provider 不存在: " + string(name)})
return
}
switch action {
case "enable":
h.balancer.EnableProvider(name)
slog.Info("管理: 启用 provider", "provider", name, "from", clientIP)
writeJSON(w, http.StatusOK, map[string]string{"status": "enabled", "provider": string(name)})
case "disable":
h.balancer.DisableProvider(name)
slog.Info("管理: 禁用 provider", "provider", name, "from", clientIP)
writeJSON(w, http.StatusOK, map[string]string{"status": "disabled", "provider": string(name)})
default:
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "未知操作: " + action + "(可用: enable, disable)"})
}
}
// handleCacheClear 清空缓存。
// POST /admin/cache/clear
func (h *Handler) handleCacheClear(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
h.cache.Clear()
slog.Info("管理: 缓存已清空", "from", requestIP(r))
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
}
// handleKeys 列出所有密钥状态。
// GET /admin/keys
func (h *Handler) handleKeys(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
type keyInfo struct {
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
}
var keys []keyInfo
for _, p := range h.providerKinds {
keys = append(keys, keyInfo{
Provider: string(p),
Enabled: h.balancer.IsProviderEnabled(p),
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"keys": keys,
})
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// requestIP 从请求中提取客户端 IP。
func requestIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
return fwd
}
// RemoteAddr 格式 "ip:port",去掉端口
if host := r.RemoteAddr; host != "" {
for i := len(host) - 1; i >= 0; i-- {
if host[i] == ':' {
return host[:i]
}
}
return host
}
return "unknown"
}
+240
View File
@@ -0,0 +1,240 @@
// Package mcp 实现 MCP 协议的工具注册、调用处理(传输层无关)。
package mcp
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"time"
"github.com/chenxin/hunjing/mcp/web-search/internal/balancer"
"github.com/chenxin/hunjing/mcp/web-search/internal/cache"
"github.com/chenxin/hunjing/mcp/web-search/internal/config"
"github.com/chenxin/hunjing/mcp/web-search/internal/metrics"
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
)
// ToolDefinition MCP 工具定义。
type ToolDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema interface{} `json:"inputSchema"`
}
// CallToolRequest MCP tools/call JSON-RPC 参数。
type CallToolRequest struct {
Name string `json:"name"`
Input json.RawMessage `json:"input,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"` // MCP 标准字段名
}
// CallToolResult MCP tools/call 响应体。
type CallToolResult struct {
Content []ContentItem `json:"content"`
IsError bool `json:"isError,omitempty"`
}
// ContentItem MCP 内容项。
type ContentItem struct {
Type string `json:"type"`
Text string `json:"text"`
}
// Handler 搜索工具核心处理逻辑(传输层无关)。
type Handler struct {
balancer *balancer.LoadBalancer
cache *cache.Manager
providerKinds []models.ProviderKind
}
func NewHandler(b *balancer.LoadBalancer, c *cache.Manager, providers []models.ProviderKind) *Handler {
return &Handler{
balancer: b,
cache: c,
providerKinds: providers,
}
}
// ReloadConfig 应用配置热重载变更。
// 由 config.Reloader 在文件变更后调用,只处理 YAML 中可动态变更的字段。
func (h *Handler) ReloadConfig(oldCfg, newCfg *config.Config) {
// Provider 启用/禁用
type pc struct {
name models.ProviderKind
old config.ProviderConfig
new config.ProviderConfig
}
changes := []pc{
{models.ProviderTavily, oldCfg.Providers.Tavily, newCfg.Providers.Tavily},
{models.ProviderBrave, oldCfg.Providers.Brave, newCfg.Providers.Brave},
}
for _, c := range changes {
if !c.old.Enabled && c.new.Enabled {
h.balancer.EnableProvider(c.name)
slog.Info("配置热重载: 启用 provider", "name", c.name)
} else if c.old.Enabled && !c.new.Enabled {
h.balancer.DisableProvider(c.name)
slog.Info("配置热重载: 禁用 provider", "name", c.name)
}
}
// 缓存 TTL
if oldCfg.Cache.TTL != newCfg.Cache.TTL {
h.cache.SetTTL(newCfg.Cache.TTL)
slog.Info("配置热重载: 缓存 TTL 更新", "old", oldCfg.Cache.TTL, "new", newCfg.Cache.TTL)
}
// 负载均衡重试次数
if oldCfg.Balancer.RetryCount != newCfg.Balancer.RetryCount {
h.balancer.SetRetryCount(newCfg.Balancer.RetryCount)
slog.Info("配置热重载: 重试次数更新", "old", oldCfg.Balancer.RetryCount, "new", newCfg.Balancer.RetryCount)
}
}
// ToolDefinition 返回 web_search 工具定义(MCP tools/list 用)。
func (h *Handler) ToolDefinition() ToolDefinition {
return ToolDefinition{
Name: "web_search",
Description: "联网搜索,获取最新的网络信息。支持多搜索引擎。",
InputSchema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{
"type": "string",
"description": "搜索关键词",
},
"max_results": map[string]interface{}{
"type": "integer",
"default": 5,
"minimum": 1,
"maximum": 20,
"description": "最大返回结果数",
},
"mode": map[string]interface{}{
"type": "string",
"enum": []string{"first", "merge"},
"default": "first",
"description": "搜索模式:first(首个成功返回,默认)、merge(并发搜索所有引擎,去重合并)",
},
"providers": map[string]interface{}{
"type": "array",
"items": map[string]interface{}{
"type": "string",
},
"description": "优先使用的搜索引擎(默认全部可用引擎)",
},
},
"required": []string{"query"},
},
}
}
// HandleSearch 执行搜索(被 stdio、SSE、HTTP 三种传输层共享)。
func (h *Handler) HandleSearch(ctx context.Context, req *models.SearchRequest) *CallToolResult {
start := time.Now()
// 校验
if req.Query == "" {
metrics.SearchErrors.WithLabelValues("empty_query").Inc()
return errorResult("搜索关键词不能为空")
}
if req.MaxResults <= 0 {
req.MaxResults = 5
}
if req.MaxResults > 20 {
req.MaxResults = 20
}
if req.Mode == "" {
req.Mode = models.ModeFirst
}
// 查缓存(用空 provider 查跨 provider 缓存)
if cached := h.cache.Get(req, ""); cached != nil {
metrics.CacheHits.Inc()
metrics.SearchDuration.Observe(time.Since(start).Seconds())
return successResult(cached)
}
metrics.CacheMisses.Inc()
// 执行搜索
if req.Mode == models.ModeMerge {
return h.mergeSearch(ctx, req, start)
}
result, err := h.balancer.Search(ctx, req)
if err != nil {
metrics.SearchErrors.WithLabelValues("search_failed").Inc()
return errorResult(fmt.Sprintf("搜索失败: %v", err))
}
// 写入缓存
h.cache.Set(req, result.Provider, result)
metrics.SearchTotal.WithLabelValues(string(result.Provider)).Inc()
return successResult(result)
}
func successResult(result *models.SearchResult) *CallToolResult {
jsonBytes, _ := json.Marshal(result)
return &CallToolResult{
Content: []ContentItem{
{Type: "json", Text: string(jsonBytes)},
},
}
}
func errorResult(msg string) *CallToolResult {
errJSON, _ := json.Marshal(map[string]string{"error": msg})
return &CallToolResult{
IsError: true,
Content: []ContentItem{
{Type: "text", Text: string(errJSON)},
},
}
}
// mergeSearch 并发搜索所有 provider,合并去重后返回。
func (h *Handler) mergeSearch(ctx context.Context, req *models.SearchRequest, start time.Time) *CallToolResult {
results := h.balancer.SearchAll(ctx, req)
if len(results) == 0 {
metrics.SearchErrors.WithLabelValues("merge_no_results").Inc()
return errorResult("所有搜索引擎均未返回结果")
}
// 合并 + URL 去重
seen := make(map[string]bool)
var merged []models.ResultItem
for _, res := range results {
for _, item := range res.Results {
if seen[item.URL] {
continue
}
seen[item.URL] = true
merged = append(merged, item)
}
}
// 按 score 降序排序(无 score 的排最后)
sort.Slice(merged, func(i, j int) bool {
return merged[i].Score > merged[j].Score
})
// 截断到 max_results
if len(merged) > req.MaxResults {
merged = merged[:req.MaxResults]
}
final := &models.SearchResult{
Provider: models.ProviderKind("merge"),
Query: req.Query,
Results: merged,
TotalCount: len(merged),
DurationMs: time.Since(start).Milliseconds(),
Cached: false,
}
h.cache.Set(req, "", final)
return successResult(final)
}
@@ -0,0 +1,43 @@
package mcp
import (
"context"
"testing"
"github.com/chenxin/hunjing/mcp/web-search/internal/cache"
"github.com/chenxin/hunjing/mcp/web-search/internal/config"
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
)
func TestHandler_ToolDefinition(t *testing.T) {
h := NewHandler(nil, nil, nil)
def := h.ToolDefinition()
if def.Name != "web_search" {
t.Errorf("expected 'web_search', got '%s'", def.Name)
}
if def.Description == "" {
t.Error("expected non-empty description")
}
}
func TestHandler_HandleSearch_EmptyQuery(t *testing.T) {
c, _ := cache.New(config.CacheConfig{Enabled: false})
h := NewHandler(nil, c, nil)
result := h.HandleSearch(context.Background(), &models.SearchRequest{Query: ""})
if !result.IsError {
t.Fatal("expected error for empty query")
}
}
func TestHandler_HandleSearch_DefaultMaxResults(t *testing.T) {
req := &models.SearchRequest{Query: "test"}
if req.MaxResults <= 0 {
req.MaxResults = 5
}
if req.MaxResults > 20 {
req.MaxResults = 20
}
if req.MaxResults != 5 {
t.Errorf("expected default 5, got %d", req.MaxResults)
}
}
+242
View File
@@ -0,0 +1,242 @@
package mcp
import (
"fmt"
"net/http"
"time"
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
)
// HandleMonitor 返回 HTML 监控面板(GET /)。
func (h *Handler) HandleMonitor(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
uptime := time.Since(startTime).Round(time.Second).String()
// Provider 表格行
var providerRows string
for _, p := range h.providerKinds {
counts := h.balancer.ActiveKeyCount()
activeKeys := counts[p]
enabled := h.balancer.IsProviderEnabled(p)
statusIcon := "🟢"
statusText := "启用"
if !enabled {
statusIcon = "🔴"
statusText = "禁用"
}
providerRows += fmt.Sprintf(`<tr>
<td>%s</td>
<td><span class="badge badge-provider">%s</span></td>
<td>%s <span class="status-text">%s</span></td>
<td class="num">%d</td>
</tr>`, statusIcon, p, statusIcon, statusText, activeKeys)
}
// 缓存统计
l1Hits, l2Hits, misses := h.cache.Stats()
hitRate := h.cache.HitRate()
html := fmt.Sprintf(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>博闻 · 监控面板</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans SC", sans-serif;
background: #0f172a;
color: #e2e8f0;
padding: 24px;
line-height: 1.6;
}
.container { max-width: 960px; margin: 0 auto; }
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 12px;
}
h1 small {
font-size: 14px;
font-weight: 400;
color: #64748b;
}
.subtitle {
color: #64748b;
font-size: 14px;
margin-bottom: 24px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.card {
background: #1e293b;
border-radius: 12px;
padding: 16px 20px;
border: 1px solid #334155;
}
.card .label {
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 4px;
}
.card .value {
font-size: 28px;
font-weight: 700;
color: #38bdf8;
}
.card .value.green { color: #4ade80; }
.card .value.yellow { color: #fbbf24; }
.card .value.red { color: #f87171; }
.section-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: #94a3b8;
}
table {
width: 100%%;
border-collapse: collapse;
background: #1e293b;
border-radius: 12px;
overflow: hidden;
border: 1px solid #334155;
margin-bottom: 24px;
}
th {
text-align: left;
padding: 12px 16px;
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
background: #1a2332;
border-bottom: 1px solid #334155;
}
td {
padding: 10px 16px;
border-bottom: 1px solid #1e293b;
font-size: 14px;
}
tr:last-child td { border-bottom: none; }
tr:hover { background: #243348; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.badge-provider {
background: #1e3a5f;
color: #93c5fd;
}
.status-text {
font-size: 13px;
color: #94a3b8;
margin-left: 4px;
}
a { color: #38bdf8; text-decoration: none; }
a:hover { text-decoration: underline; }
.links { margin-top: 16px; display: flex; gap: 16px; font-size: 14px; }
.footer { margin-top: 32px; padding-top: 16px; border-top: 1px solid #334155; color: #475569; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<h1>🔍 博闻 <small>Bowen Web Search</small></h1>
<div class="subtitle">运行时间 %s</div>
<div class="grid">
<div class="card">
<div class="label">Provider</div>
<div class="value">%d</div>
</div>
<div class="card">
<div class="label">活跃密钥</div>
<div class="value">%d</div>
</div>
<div class="card">
<div class="label">缓存命中率</div>
<div class="value %s">%d%%%%</div>
</div>
<div class="card">
<div class="label">缓存条目</div>
<div class="value">%d</div>
</div>
</div>
<div class="section-title">📡 Provider 状态</div>
<table>
<thead>
<tr><th>状态</th><th>名称</th><th>开关</th><th class="num">活跃密钥</th></tr>
</thead>
<tbody>%s</tbody>
</table>
<div class="section-title">💾 缓存统计</div>
<table>
<thead>
<tr><th>指标</th><th class="num">值</th></tr>
</thead>
<tbody>
<tr><td>L1 命中(内存 LRU)</td><td class="num">%d</td></tr>
<tr><td>L2 命中(Redis)</td><td class="num">%d</td></tr>
<tr><td>未命中</td><td class="num">%d</td></tr>
<tr><td>总命中率</td><td class="num">%.1f%%%%</td></tr>
</tbody>
</table>
<div class="links">
<span>🔗</span>
<a href="/status">/status (JSON)</a>
<a href="/admin/keys">/admin/keys</a>
<a href="/health">/health</a>
</div>
<div class="footer">博闻 v2.0.0 · 数据实时 · 页面每 30 秒刷新</div>
</div>
<script>setTimeout(function(){ location.reload(); }, 30000);</script>
</body>
</html>`,
uptime,
len(h.providerKinds),
sumActiveKeys(h.balancer.ActiveKeyCount()),
hitRateColor(hitRate),
int(hitRate*100),
l1Hits+l2Hits+misses,
providerRows,
l1Hits, l2Hits, misses, hitRate*100,
)
fmt.Fprint(w, html)
}
func sumActiveKeys(m map[models.ProviderKind]int) int {
s := 0
for _, v := range m {
s += v
}
return s
}
func hitRateColor(rate float64) string {
if rate >= 0.6 {
return "green"
} else if rate >= 0.3 {
return "yellow"
}
return "red"
}
+101
View File
@@ -0,0 +1,101 @@
package mcp
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
)
// LoggingMiddleware 记录 HTTP 请求的源 IP、方法、路径和处理耗时。
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ip := r.RemoteAddr
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
ip = fwd
}
method := r.Method
path := r.URL.Path
next.ServeHTTP(w, r)
slog.Info("HTTP",
"method", method,
"path", path,
"from", ip,
"ms", time.Since(start).Milliseconds(),
)
})
}
// ServeHTTP 启动 Streamable HTTP 传输模式的 MCP 服务。
//
// 传输协议:MCP Streamable HTTP(JSON-RPC over HTTP POST /)
// POST / → MCP 处理,GET / → 帮助页
func (h *Handler) ServeHTTP(addr string) error {
mux := http.NewServeMux()
mux.HandleFunc("/", h.handleRoot)
// 管理端点
mux.HandleFunc("/health", h.HandleHealth)
mux.HandleFunc("/status", h.handleStatus)
h.registerAdminRoutes(mux)
slog.Info("博闻 HTTP 服务启动", "addr", addr, "providers", h.providerKinds)
return http.ListenAndServe(addr, LoggingMiddleware(mux))
}
// handleRoot MCP 服务根路径。
// POST → MCP Streamable HTTP(JSON-RPC 2.0)
// GET → 帮助页
func (h *Handler) handleRoot(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
h.handleStreamableHTTP(w, r)
return
}
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprint(w, `博闻 Bowen · MCP Web Search
传输协议:
POST / MCP Streamable HTTP(JSON-RPC 2.0,单端点)
管理端点:
GET /health 存活探针
GET /status 系统状态
GET /admin/keys 密钥状态
PUT /admin/provider/:name/enable 启用 provider
PUT /admin/provider/:name/disable 禁用 provider
POST /admin/cache/clear 清空缓存
监控面板(metrics 端口):
GET / HTML 仪表盘
GET /metrics Prometheus 原始指标
Stdio 模式:go run ./cmd/server --stdio
`)
}
// handleStreamableHTTP MCP Streamable HTTP 传输端点。
// 接收 JSON-RPC 2.0 POST 请求,返回 JSON-RPC 响应。
// 现阶段返回完整响应(非流式),后续可扩展为 text/event-stream 流式传输。
func (h *Handler) handleStreamableHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req JSONRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(jsonRPCError(nil, -32700, "Parse error"))
return
}
resp := h.handleJSONRPC(req)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
+75
View File
@@ -0,0 +1,75 @@
package mcp
import (
"encoding/json"
"net/http"
"time"
)
// SystemStatus 博闻系统状态(通过 /status 或管理 API 查询)。
type SystemStatus struct {
Version string `json:"version"`
Uptime string `json:"uptime"`
Providers []ProviderStatus `json:"providers"`
Cache CacheStats `json:"cache"`
}
// ProviderStatus provider 状态摘要。
type ProviderStatus struct {
Name string `json:"name"`
ActiveKeys int `json:"active_keys"`
Enabled bool `json:"enabled"`
}
// CacheStats 缓存状态摘要。
type CacheStats struct {
Enabled bool `json:"enabled"`
L1Hits int64 `json:"l1_hits"`
L2Hits int64 `json:"l2_hits"`
Misses int64 `json:"misses"`
HitRate float64 `json:"hit_rate"`
}
// startTime 记录博闻启动时间,用于 /status 的 uptime 计算。
var startTime = time.Now()
// HandleHealth 存活探针(GET /health → 200)。
func (h *Handler) HandleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleStatus 系统状态(GET /status → SystemStatus)。
func (h *Handler) handleStatus(w http.ResponseWriter, r *http.Request) {
// Provider 状态
var providers []ProviderStatus
for _, p := range h.providerKinds {
counts := h.balancer.ActiveKeyCount()
activeKeys := counts[p]
providers = append(providers, ProviderStatus{
Name: string(p),
ActiveKeys: activeKeys,
Enabled: activeKeys > 0,
})
}
// 缓存状态
l1Hits, l2Hits, misses := h.cache.Stats()
cacheStats := CacheStats{
Enabled: true,
L1Hits: l1Hits,
L2Hits: l2Hits,
Misses: misses,
HitRate: h.cache.HitRate(),
}
status := SystemStatus{
Version: "2.0.0",
Uptime: time.Since(startTime).Round(time.Second).String(),
Providers: providers,
Cache: cacheStats,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
+111
View File
@@ -0,0 +1,111 @@
package mcp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"github.com/chenxin/hunjing/mcp/web-search/internal/models"
)
// JSONRPCRequest JSON-RPC 2.0 请求。
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID *int `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// JSONRPCResponse JSON-RPC 2.0 响应。
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID *int `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
// RPCError JSON-RPC 错误。
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// ServeStdio 通过 stdin/stdout 提供 MCP JSON-RPC 服务。
func (h *Handler) ServeStdio() {
slog.Info("博闻 stdio 模式启动", "providers", h.providerKinds)
reader := bufio.NewReader(os.Stdin)
encoder := json.NewEncoder(os.Stdout)
for {
line, err := reader.ReadString('\n')
if err != nil {
slog.Error("stdio read error", "error", err)
return
}
var req JSONRPCRequest
if err := json.Unmarshal([]byte(line), &req); err != nil {
slog.Warn("stdio parse error", "error", err)
continue
}
resp := h.handleJSONRPC(req)
if err := encoder.Encode(resp); err != nil {
slog.Error("stdio write error", "error", err)
}
}
}
func (h *Handler) handleJSONRPC(req JSONRPCRequest) JSONRPCResponse {
switch req.Method {
case "tools/list":
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]interface{}{
"tools": []ToolDefinition{h.ToolDefinition()},
},
}
case "tools/call":
var callReq CallToolRequest
if err := json.Unmarshal(req.Params, &callReq); err != nil {
return jsonRPCError(req.ID, -32602, "Invalid params")
}
if callReq.Name != "web_search" {
return jsonRPCError(req.ID, -32601, fmt.Sprintf("Tool not found: %s", callReq.Name))
}
// 优先使用 MCP 标准 arguments 字段,兼容旧 input 字段
rawInput := callReq.Arguments
if len(rawInput) == 0 {
rawInput = callReq.Input
}
var searchReq models.SearchRequest
if err := json.Unmarshal(rawInput, &searchReq); err != nil {
return jsonRPCError(req.ID, -32602, "Invalid search request parameters: "+err.Error())
}
result := h.HandleSearch(context.Background(), &searchReq)
return JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: result,
}
default:
return jsonRPCError(req.ID, -32601, fmt.Sprintf("Method not found: %s", req.Method))
}
}
func jsonRPCError(id *int, code int, msg string) JSONRPCResponse {
return JSONRPCResponse{
JSONRPC: "2.0",
ID: id,
Error: &RPCError{Code: code, Message: msg},
}
}