Files
Agent-Native/runtime/mcp/manager.go
T
weichen fcd2539b9e
ci / vet-and-test (pull_request) Successful in 52s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Align the MCP client with 2026-07-28: resultType, input_required retry, tasks surface (N2 WP3)
Why: the completion doc requires the client to recognize resultType,
treat input_required as a protocol-level result whose answer retries the
original tools/call, and expose tasks/get|update|cancel; today only a
content-block special case existed and the advertised version was never
checked against the server's.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestInputRequiredResultTypeRetriesOriginalCall (ask → tasks/update →
retry, call order and final content), TestNegotiatedVersionFallbackKeepsLegacyPath
(byte-level legacy behavior on 2024-11-05), TestTasksGetAndCancelRoundTrip,
TestRunEndCancelsSeenTasks, TestDefaultSchemaDeclares202012; the
acceptance-③ regression TestStdioSlowCancelThenEcho passes unchanged.
Impact: advertise 2026-07-28 and branch on the negotiated version
(older servers keep byte-identical behavior); synthesized default tool
schemas now declare JSON Schema 2020-12; runs cancel the MCP tasks they
saw via a per-run TaskTracker (other runs on the shared manager are
unaffected).
Refs: Agent-System-Documents#42 (N2 plan WP3)
2026-10-07 13:38:51 +00:00

341 lines
9.7 KiB
Go
Raw 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"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"agent-native/runtime/tools"
)
// 桥接上限:server 元数据是不可信输入,长度与数量都必须受限。
const (
maxToolsPerServer = 64
maxDescriptionBytes = 1024
maxTextResultBytes = 64 << 10
maxToolNameBytes = 128
callTimeoutDefault = 30 * time.Second
registerTimeoutFixed = 30 * time.Second
// 工具数超过这个值且没有显式暴露级别时,整台服务器改为延迟暴露。
deferredToolThreshold = 8
)
// Manager 维护一组 MCP server 连接,并把它们的工具注册进 tools.Registry。
// 零值不可用;由组合根显式构造并注入(未注入 = 无 MCP 工具,fail-closed)。
type Manager struct {
configs []ServerConfig
mu sync.Mutex
clients map[string]Client
}
func NewManager(configs []ServerConfig) (*Manager, error) {
seen := map[string]bool{}
for _, c := range configs {
if err := c.Validate(); err != nil {
return nil, err
}
if seen[c.Name] {
return nil, fmt.Errorf("mcp server %q configured twice", c.Name)
}
seen[c.Name] = true
}
return &Manager{configs: configs, clients: map[string]Client{}}, nil
}
// Configs 返回声明的 server 清单(只读副本)。
func (m *Manager) Configs() []ServerConfig {
out := make([]ServerConfig, len(m.configs))
copy(out, m.configs)
return out
}
func (m *Manager) client(ctx context.Context, cfg ServerConfig) (Client, error) {
m.mu.Lock()
defer m.mu.Unlock()
if c, ok := m.clients[cfg.Name]; ok {
return c, nil
}
dialCtx, cancel := context.WithTimeout(ctx, registerTimeoutFixed)
defer cancel()
var c Client
var err error
if cfg.Command != "" {
c, err = DialStdio(dialCtx, cfg)
} else {
c, err = DialHTTP(dialCtx, cfg)
}
if err != nil {
return nil, fmt.Errorf("mcp server %q unreachable (fail-closed): %w", cfg.Name, err)
}
m.clients[cfg.Name] = c
return c, nil
}
// ApplyOverrides 用项目配置覆盖启用状态和暴露级别。空字符串不改。
func (m *Manager) ApplyOverrides(raw string) error {
if m == nil || strings.TrimSpace(raw) == "" {
return nil
}
var over map[string]struct {
Enabled *bool `json:"enabled"`
Exposure string `json:"exposure"`
}
if err := json.Unmarshal([]byte(raw), &over); err != nil {
return err
}
for i, cfg := range m.configs {
o, ok := over[cfg.Name]
if !ok {
continue
}
if o.Enabled != nil {
m.configs[i].Enabled = o.Enabled
}
if o.Exposure != "" {
m.configs[i].Exposure = o.Exposure
}
if err := m.configs[i].Validate(); err != nil {
return err
}
}
return nil
}
// Ready 报告已启用的服务器是否都已经连上。已连上时注册只是枚举工具,不再握手。
func (m *Manager) Ready() bool {
if m == nil {
return true
}
m.mu.Lock()
defer m.mu.Unlock()
for _, cfg := range m.configs {
if !cfg.IsEnabled() {
continue
}
if _, ok := m.clients[cfg.Name]; !ok {
return false
}
}
return true
}
func (m *Manager) StartBackground(ctx context.Context, reg *tools.Registry) {
if m == nil || reg == nil {
return
}
for _, cfg := range m.configs {
cfg := cfg
if !cfg.IsEnabled() {
continue
}
go func() { _ = m.registerServer(ctx, reg, cfg) }()
}
}
// RegisterInto 连接全部已启用的 server 并注册工具。未启用的跳过。
func (m *Manager) RegisterInto(ctx context.Context, reg *tools.Registry) error {
for _, cfg := range m.configs {
if !cfg.IsEnabled() {
continue
}
if err := m.registerServer(ctx, reg, cfg); err != nil {
return err
}
}
return nil
}
func (m *Manager) registerServer(ctx context.Context, reg *tools.Registry, cfg ServerConfig) error {
c, err := m.client(ctx, cfg)
if err != nil {
return err
}
listCtx, cancel := context.WithTimeout(ctx, registerTimeoutFixed)
list, err := c.ListTools(listCtx)
cancel()
if err != nil {
return fmt.Errorf("mcp server %q tools/list failed: %w", cfg.Name, err)
}
if len(list) > maxToolsPerServer {
return fmt.Errorf("mcp server %q exposes %d tools (max %d)", cfg.Name, len(list), maxToolsPerServer)
}
exposure := chooseExposure(cfg.Exposure, len(list))
for _, ti := range list {
name := ToolName(cfg.Name, ti.Name)
if len(name) > maxToolNameBytes {
return fmt.Errorf("mcp tool name too long: %s", name)
}
desc := ti.Description
if len(desc) > maxDescriptionBytes {
desc = tools.CutUTF8(desc, maxDescriptionBytes) + "…[truncated]"
}
schema := ti.InputSchema
if len(schema) == 0 {
schema = json.RawMessage(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{}}`)
}
reg.Register(&bridgeTool{
client: c,
serverCfg: cfg,
toolName: ti.Name,
declName: name,
description: desc,
schema: schema,
exposure: exposure,
redial: func(ctx context.Context) (Client, error) {
m.mu.Lock()
delete(m.clients, cfg.Name)
m.mu.Unlock()
return m.client(ctx, cfg)
},
})
}
return nil
}
func chooseExposure(explicit string, n int) string {
switch explicit {
case tools.ExposureDirect, tools.ExposureDeferred:
return explicit
default:
if n > deferredToolThreshold {
return tools.ExposureDeferred
}
return tools.ExposureDirect
}
}
// ToolName 生成桥接工具名(命名空间隔离)。
func ToolName(server, tool string) string {
return "mcp__" + server + "__" + tool
}
// Close 关闭全部连接(进程型 server 随之退出)。
func (m *Manager) Close() {
m.mu.Lock()
defer m.mu.Unlock()
for _, c := range m.clients {
_ = c.Close()
}
m.clients = map[string]Client{}
}
// bridgeTool 把一个 MCP 工具适配为 tools.Tool。CallTool 失败时重连一次再试。
type bridgeTool struct {
client Client
serverCfg ServerConfig
toolName string
declName string
description string
schema json.RawMessage
exposure string
redial func(ctx context.Context) (Client, error)
}
func (t *bridgeTool) Name() string { return t.declName }
func (t *bridgeTool) Description() string { return t.description }
func (t *bridgeTool) Parameters() json.RawMessage { return t.schema }
func (t *bridgeTool) Exposure() string { return t.exposure }
func (t *bridgeTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
res, err := t.call(ctx, args)
if err != nil && t.redial != nil {
redial := t.redial
t.redial = nil
if c, rerr := redial(ctx); rerr == nil {
t.client = c
res, err = t.call(ctx, args)
}
}
if err != nil {
return tools.Result{Content: fmt.Sprintf("mcp tool %s failed: %v", t.declName, err), IsError: true}, nil
}
// input_required:2026-07-28 协议级结果(取应答后先 tasks/update 再
// 用原参数重试 tools/call);旧版内容块路径保持原样。
if question, ok := inputQuestion(res, t.client.NegotiatedVersion()); ok && res.ResultType == "input_required" && SupportsResultType(t.client.NegotiatedVersion()) {
return t.answerAndRetry(ctx, args, res, question)
} else if ok {
ask := askFrom(ctx)
if ask == nil {
return tools.Result{Content: "需要用户输入:" + question, IsError: true}, nil
}
answer, aerr := ask(ctx, question)
if aerr != nil {
return tools.Result{Content: aerr.Error(), IsError: true}, nil
}
return tools.Result{Content: answer}, nil
}
var b strings.Builder
for _, blk := range res.Content {
if blk.Type == "text" && blk.Text != "" {
b.WriteString(blk.Text)
b.WriteString("\n")
}
}
out := b.String()
if len(out) > maxTextResultBytes {
out = tools.CutUTF8(out, maxTextResultBytes) + "\n[truncated]"
}
if res.IsError {
return tools.Result{Content: out, IsError: true}, nil
}
return tools.Result{Content: out}, nil
}
// maxInputRounds 是 input_required 应答重试的上限(防服务端循环要输入)。
const maxInputRounds = 3
// answerAndRetry 处理 2026-07-28 的协议级 input_required:经 ask 端口取
// 应答,TaskID 非空时先 tasks/update 投递,然后用原参数重试 tools/call;
// 服务端连续要输入最多 maxInputRounds 轮。
func (t *bridgeTool) answerAndRetry(ctx context.Context, args json.RawMessage, res callToolResult, question string) (tools.Result, error) {
for round := 0; round < maxInputRounds; round++ {
ask := askFrom(ctx)
if ask == nil {
return tools.Result{Content: "需要用户输入:" + question, IsError: true}, nil
}
answer, aerr := ask(ctx, question)
if aerr != nil {
return tools.Result{Content: aerr.Error(), IsError: true}, nil
}
if res.TaskID != "" {
if uerr := t.client.TasksUpdate(ctx, res.TaskID, answer); uerr != nil {
return tools.Result{Content: fmt.Sprintf("mcp task update failed: %v", uerr), IsError: true}, nil
}
}
var err error
res, err = t.call(ctx, args)
if err != nil {
return tools.Result{Content: fmt.Sprintf("mcp tool %s failed: %v", t.declName, err), IsError: true}, nil
}
q, ok := inputQuestion(res, t.client.NegotiatedVersion())
if !ok || res.ResultType != "input_required" || !SupportsResultType(t.client.NegotiatedVersion()) {
var b strings.Builder
for _, blk := range res.Content {
if blk.Type == "text" && blk.Text != "" {
b.WriteString(blk.Text)
b.WriteString("\n")
}
}
out := b.String()
if res.IsError {
return tools.Result{Content: out, IsError: true}, nil
}
return tools.Result{Content: strings.TrimSpace(out)}, nil
}
question = q
}
return tools.Result{Content: "需要用户输入:" + question, IsError: true}, nil
}
func (t *bridgeTool) call(ctx context.Context, args json.RawMessage) (callToolResult, error) {
callCtx, cancel := context.WithTimeout(ctx, requestTimeout(t.serverCfg))
defer cancel()
res, err := t.client.CallTool(callCtx, t.toolName, args)
if err == nil && res.TaskID != "" {
trackerFrom(ctx).note(t.client, res.TaskID)
}
return res, err
}