package engine // elicitation_adapter.go - bridges engine.ElicitationHandler (SDK-friendly types) // to mcp.ElicitationHandler (wire types from MCP 2025-03-26 spec). // // Why two parallel type systems exist (see internal/mcp/client.go:114-120): // - mcp package can't import engine (would create internal/mcp -> pkg/engine // dependency, internal packages must not depend on public packages). // - engine package wants SDK-friendly types (Fields slice instead of map, // ServerName plain string, etc.). // // This adapter is the only bridge -- engine.New constructs mcp.Manager and // immediately registers `adaptElicitationHandler(cfg.ElicitationHandler)`. Without // this wiring, every elicitation/create request from any MCP server is silently // auto-cancelled (mcp.Client falls back to nil-handler path), which broke the // "Config.ElicitationHandler 处理 MCP 服务器发出的用户输入请求" godoc promise -- // consumers could set the handler but it was never invoked. // // elicitation_adapter.go - 把 engine.ElicitationHandler (SDK 友好类型) 桥接到 // mcp.ElicitationHandler (MCP 2025-03-26 spec wire 类型). // // 为什么两套并行类型系统 (见 internal/mcp/client.go:114-120): // - mcp 包不能 import engine (否则 internal/mcp -> pkg/engine 依赖, internal // 包不应依赖 public 包). // - engine 包想要 SDK 友好类型 (Fields 切片而非 map, ServerName 原始 string 等). // // 这个 adapter 是唯一桥梁 -- engine.New 构造 mcp.Manager 后立刻注册 // `adaptElicitationHandler(cfg.ElicitationHandler)`. 没接线时, 任何 MCP server 发 // 的 elicitation/create 请求都被无声 auto-cancel (mcp.Client fallback 到 nil // handler 路径), 破坏了 "Config.ElicitationHandler 处理 MCP 服务器发出的用户输入 // 请求" godoc 承诺 -- 消费层设了 handler 但永远不会被调用. import ( "encoding/json" "fmt" "git.flytoex.net/yuanwei/flyto-agent/internal/mcp" "git.flytoex.net/yuanwei/flyto-agent/pkg/flyto" ) // adaptElicitationHandler wraps an engine.ElicitationHandler so it satisfies the // internal mcp.ElicitationHandler interface. nil engine handler degrades to // NoopElicitationHandler (auto-cancel) to match the engine.Config.ElicitationHandler // godoc: "nil 时自动使用 NoopElicitationHandler (返回 cancel,不阻塞,不 panic)". // // adaptElicitationHandler 包装 engine.ElicitationHandler 使其满足内部 // mcp.ElicitationHandler 接口. nil engine handler 退化为 NoopElicitationHandler // (auto-cancel) 以匹配 engine.Config.ElicitationHandler godoc: "nil 时自动使用 // NoopElicitationHandler (返回 cancel,不阻塞,不 panic)". func adaptElicitationHandler(h ElicitationHandler, obs flyto.EventObserver) mcp.ElicitationHandler { if h == nil { h = NoopElicitationHandler{} } return &elicitationHandlerAdapter{inner: h, observer: obs} } // elicitationHandlerAdapter implements mcp.ElicitationHandler by delegating to // an engine.ElicitationHandler with type/shape conversion on both directions. // observer (may be nil) receives diagnostic events when the consumer handler // fails -- without this, handler errors silently coerce to cancel, leaving // production triage with no breadcrumb. // // observer (可能 nil) 在消费层 handler 失败时收到诊断事件 -- 没这个的话, handler // 错误默默被强转 cancel, 生产环境排查时连面包屑都没有. type elicitationHandlerAdapter struct { inner ElicitationHandler observer flyto.EventObserver } // HandleElicitation converts mcp wire request -> engine SDK request, calls the // engine handler, then converts engine response -> mcp wire result. // // Default-fill semantics on the response path: when the user accepts but omits // an optional field that has a schema Default, the adapter auto-fills the // Default into mcp Content. This matches typical web-form UX ("if user didn't // override the prefilled default, send the default") and gives consumer-layer // handlers an escape hatch from re-implementing the same logic in every UI. // Spec-wise the MCP 2025-03-26 elicitation spec is silent on auto-fill, so // this is an engine-layer convenience layered on top. // // HandleElicitation 把 mcp wire 请求转 engine SDK 请求, 调 engine handler, // 然后把 engine 响应转回 mcp wire 结果. // // 响应路径上的 Default-fill 语义: 用户 accept 但跳过了一个有 schema Default 的 // 可选字段时, adapter 把 Default 自动填到 mcp Content. 这匹配典型 web 表单 UX // ("用户没改预填默认值就用默认值"), 让消费层 handler 不必每个 UI 自己重实现 // 这套逻辑. spec 上 MCP 2025-03-26 elicitation 对 auto-fill 没规定, 这是引擎 // 层叠加的便利. func (a *elicitationHandlerAdapter) HandleElicitation(serverName, message string, schema *mcp.ElicitationSchema) mcp.ElicitationCreateResult { fields := mcpSchemaToEngineFields(schema) engineReq := ElicitationRequest{ ServerName: serverName, Message: message, Fields: fields, } engineResp, err := a.inner.HandleElicitation(engineReq) if err != nil { // Handler error is treated as cancel (best-effort fail-safe). Spec doesn't // define a transport-level error code for handler failures, and surfacing // raw Go errors over the wire risks leaking internal info to the MCP server. // // handler 出错按 cancel 处理 (best-effort fail-safe). spec 没为 handler // 失败定义 transport 层错误码, 把 Go 原始错误透到 wire 上有泄漏内部信息 // 给 MCP server 的风险. if a.observer != nil { a.observer.Event("elicitation_handler_error", map[string]any{ "server_name": serverName, "error": err.Error(), "field_count": len(fields), }) } return mcp.ElicitationCreateResult{Action: "cancel"} } return engineResponseToMCPResult(engineResp, fields) } // mcpSchemaToEngineFields flattens mcp.ElicitationSchema (object with map of // properties) into a slice of engine.ElicitationField. The map -> slice // conversion loses ordering (Go map iteration is randomized) which is // acceptable here: the MCP spec doesn't define field ordering, and consumer // UIs typically render alphabetically anyway. // // mcpSchemaToEngineFields 把 mcp.ElicitationSchema (object + properties map) // 摊平成 engine.ElicitationField 切片. map -> slice 失序 (Go map iteration 随机) // 在这里可接受: MCP spec 没定义字段顺序, 消费层 UI 通常也按字母序渲染. func mcpSchemaToEngineFields(schema *mcp.ElicitationSchema) []ElicitationField { if schema == nil || len(schema.Properties) == 0 { return nil } requiredSet := make(map[string]bool, len(schema.Required)) for _, r := range schema.Required { requiredSet[r] = true } fields := make([]ElicitationField, 0, len(schema.Properties)) for name, prop := range schema.Properties { fields = append(fields, ElicitationField{ Name: name, Type: prop.Type, Title: prop.Title, Description: prop.Description, Required: requiredSet[name], Default: defaultAnyToString(prop.Default), }) } return fields } // defaultAnyToString reduces the schema-default `any` (json.Unmarshal of // arbitrary JSON value) to the engine.ElicitationField.Default string slot. // Strings pass through; numbers/bools become their fmt.Sprint form; arrays / // objects collapse to JSON-encoded text (rare for elicitation defaults but // possible). nil yields "" (omitted Default in the schema). // // defaultAnyToString 把 schema default `any` (任意 JSON 值的 json.Unmarshal 结果) // 缩成 engine.ElicitationField.Default 字符串槽. 字符串透传; 数/布尔变 fmt.Sprint; // 数组/对象退到 JSON 文本 (elicitation 默认值少见但可能). nil 给 "" (schema 没设 // Default). func defaultAnyToString(v any) string { if v == nil { return "" } if s, ok := v.(string); ok { return s } switch x := v.(type) { case bool, float64, float32, int, int64, int32: return fmt.Sprint(x) default: if b, err := json.Marshal(v); err == nil { return string(b) } return fmt.Sprint(v) } } // engineResponseToMCPResult builds the mcp wire result from the engine response, // applying the Default-fill convenience for accept actions. cancel/decline // pass through with empty Content (per spec, Content only meaningful for accept). // // engineResponseToMCPResult 把 engine 响应构造成 mcp wire 结果, 在 accept 时应用 // Default-fill 便利. cancel/decline 透传空 Content (spec 规定 Content 只在 accept // 时有意义). func engineResponseToMCPResult(resp ElicitationResponse, fields []ElicitationField) mcp.ElicitationCreateResult { if resp.Action != "accept" { return mcp.ElicitationCreateResult{Action: resp.Action} } content := make(map[string]any, len(resp.Values)+len(fields)) for k, v := range resp.Values { content[k] = v } for _, f := range fields { if _, present := content[f.Name]; present { continue } // Read site for ElicitationField.Default: schema-declared default takes // effect when the consumer handler accepted but didn't supply this value. // // ElicitationField.Default 的 read 点: 消费层 handler 已 accept 但未提供 // 这个值时, schema 声明的默认值生效. if f.Default != "" { content[f.Name] = f.Default } } return mcp.ElicitationCreateResult{Action: "accept", Content: content} }