返回 DeepSeek-Reasonix
mcp_app.go
根目录 / internal / tool / mcp_app.go
1 package tool
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "slices"
8 "strings"
9 )
10
11 // MCPAppResult is the host-local MCP Apps payload one tools/call can produce:
12 // the standard CallToolResult plus the resource identity a Desktop App needs
13 // to restore its UI. It travels on the call context (never the tool) and is
14 // converted to the provider-excluded provider.MCPAppPresentation by the agent.
15 type MCPAppResult struct {
16 Server string
17 Tool string
18 Generation uint64
19 ResourceURI string
20 CSP map[string][]string
21 RawResult json.RawMessage
22 Structured json.RawMessage
23 }
24
25 // maxMCPAppBytes bounds the persisted presentation copy. Oversized results
26 // keep the text form only.
27 const maxMCPAppBytes = 512 << 10
28
29 // ValidateMCPAppCallResult preserves a complete standard CallToolResult for
30 // the App bridge while enforcing the same bounded host payload used by stored
31 // presentations. Unknown fields and nested resource metadata are retained.
32 func ValidateMCPAppCallResult(raw json.RawMessage) (json.RawMessage, error) {
33 if len(raw) == 0 || len(raw) > maxMCPAppBytes {
34 return nil, fmt.Errorf("MCP App tool result is empty or exceeds %d bytes", maxMCPAppBytes)
35 }
36 var result map[string]any
37 if err := json.Unmarshal(raw, &result); err != nil || result == nil {
38 return nil, fmt.Errorf("MCP App tool result is not a JSON object")
39 }
40 if _, ok := result["content"].([]any); !ok {
41 return nil, fmt.Errorf("MCP App tool result has invalid content")
42 }
43 return append(json.RawMessage(nil), raw...), nil
44 }
45
46 // droppableMCPAppContent reports items that never enter the persisted copy:
47 // audio/video and oversized base64 data blow the budget without adding App
48 // value; the model-facing text/image forms live on the message itself.
49 func droppableMCPAppContent(item map[string]any) bool {
50 if hasOversizedInlineData(item) {
51 return true
52 }
53 switch item["type"] {
54 case "audio", "video":
55 return true
56 }
57 mime, _ := item["mimeType"].(string)
58 return strings.HasPrefix(mime, "audio/") || strings.HasPrefix(mime, "video/")
59 }
60
61 func hasOversizedInlineData(value any) bool {
62 switch typed := value.(type) {
63 case map[string]any:
64 for key, nested := range typed {
65 if key == "data" || key == "blob" {
66 if text, ok := nested.(string); ok && len(text) > 4096 {
67 return true
68 }
69 }
70 if hasOversizedInlineData(nested) {
71 return true
72 }
73 }
74 case []any:
75 return slices.ContainsFunc(typed, hasOversizedInlineData)
76 }
77 return false
78 }
79
80 func sanitizeMCPAppRawResult(raw json.RawMessage) json.RawMessage {
81 if len(raw) == 0 {
82 return nil
83 }
84 var parsed map[string]any
85 if err := json.Unmarshal(raw, &parsed); err != nil || parsed == nil {
86 return nil
87 }
88 if content, ok := parsed["content"].([]any); ok {
89 kept := make([]any, 0, len(content))
90 for _, candidate := range content {
91 item, ok := candidate.(map[string]any)
92 if !ok || !droppableMCPAppContent(item) {
93 kept = append(kept, candidate)
94 }
95 }
96 parsed["content"] = kept
97 }
98 b, err := json.Marshal(parsed)
99 if err != nil {
100 return nil
101 }
102 return b
103 }
104
105 func cloneMCPAppCSP(csp map[string][]string) map[string][]string {
106 if len(csp) == 0 {
107 return nil
108 }
109 out := make(map[string][]string, len(csp))
110 for directive, values := range csp {
111 out[directive] = append([]string(nil), values...)
112 }
113 return out
114 }
115
116 func mcpAppPersistedSize(r *MCPAppResult) int {
117 if r == nil {
118 return 0
119 }
120 wire := struct {
121 Server string `json:"server"`
122 Tool string `json:"tool"`
123 Generation uint64 `json:"generation"`
124 ResourceURI string `json:"resourceUri,omitempty"`
125 CSP map[string][]string `json:"csp,omitempty"`
126 RawResult json.RawMessage `json:"rawResult,omitempty"`
127 Structured json.RawMessage `json:"structured,omitempty"`
128 }{
129 Server: r.Server, Tool: r.Tool, Generation: r.Generation,
130 ResourceURI: r.ResourceURI, CSP: r.CSP,
131 RawResult: r.RawResult, Structured: r.Structured,
132 }
133 b, err := json.Marshal(wire)
134 if err != nil {
135 return maxMCPAppBytes + 1
136 }
137 return len(b)
138 }
139
140 // Sanitized returns the bounded presentation copy: inline audio/video and
141 // oversized base64 content items are dropped, then the whole payload is
142 // capped at maxMCPAppBytes (text fallback remains in Content).
143 func (r *MCPAppResult) Sanitized() *MCPAppResult {
144 if r == nil || strings.TrimSpace(r.Server) == "" || strings.TrimSpace(r.Tool) == "" {
145 return nil
146 }
147 out := &MCPAppResult{
148 Server: r.Server,
149 Tool: r.Tool,
150 Generation: r.Generation,
151 ResourceURI: r.ResourceURI,
152 CSP: cloneMCPAppCSP(r.CSP),
153 }
154 out.RawResult = sanitizeMCPAppRawResult(r.RawResult)
155 var structuredCopy json.RawMessage
156 if json.Valid(r.Structured) {
157 structuredCopy = append(json.RawMessage(nil), r.Structured...)
158 out.Structured = structuredCopy
159 }
160 // Enforce one aggregate persisted budget, including identity, CSP, JSON
161 // framing, RawResult, and Structured. Structured is normally duplicated in
162 // RawResult, so discard it first; then the rich result; then optional CSP.
163 if mcpAppPersistedSize(out) > maxMCPAppBytes {
164 out.Structured = nil
165 }
166 if mcpAppPersistedSize(out) > maxMCPAppBytes {
167 out.RawResult = nil
168 out.Structured = structuredCopy
169 }
170 if mcpAppPersistedSize(out) > maxMCPAppBytes {
171 out.Structured = nil
172 }
173 if mcpAppPersistedSize(out) > maxMCPAppBytes {
174 out.CSP = nil
175 }
176 if mcpAppPersistedSize(out) > maxMCPAppBytes {
177 return nil
178 }
179 return out
180 }
181
182 type mcpAppKey struct{}
183
184 // WithMCPAppCollector attaches a presentation collector the executing tool
185 // fills in. Contexts are immutable, so the callee cannot hand a value back by
186 // deriving a new ctx; the collector is a shared cell the caller reads after
187 // the call. Returns the collector alongside the derived context.
188 func WithMCPAppCollector(ctx context.Context) (context.Context, *MCPAppResult) {
189 if ctx == nil {
190 ctx = context.Background()
191 }
192 sink := &MCPAppResult{}
193 return context.WithValue(ctx, mcpAppKey{}, sink), sink
194 }
195
196 // CollectMCPAppResult fills the call's collector; a no-op without one.
197 func CollectMCPAppResult(ctx context.Context, r *MCPAppResult) {
198 if ctx == nil || r == nil {
199 return
200 }
201 if sink, ok := ctx.Value(mcpAppKey{}).(*MCPAppResult); ok && sink != nil {
202 *sink = *r
203 }
204 }
205
205 lines GO