返回 DeepSeek-Reasonix
appregistry.go
根目录 / internal / plugin / appregistry.go
1 package plugin
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "sync"
10
11 "reasonix/internal/tool"
12 )
13
14 // AppInstance is one live MCP Apps surface: an unguessable token binding the
15 // Host, server, catalog generation, originating tool call, and the resource
16 // the App renders. Tokens are capability handles — possession alone authorizes
17 // nothing beyond reading that instance's identity; every App tool call still
18 // walks the full permission pipeline.
19 type AppInstance struct {
20 Token string
21 Server string
22 Tool string
23 Generation uint64
24 CallID string
25 ResourceURI string
26
27 resourceContent string
28 resourceMIME string
29 resourceDigest string
30 resourceCSP map[string][]string
31 resourceBytes int
32 callCtx context.Context
33 cancelCalls context.CancelFunc
34 }
35
36 // AppResourceSnapshot is the immutable resource bound to one live App
37 // instance. Desktop serves this copy instead of re-reading a mutable upstream
38 // resource after the instance has been authorized.
39 type AppResourceSnapshot struct {
40 Content string
41 MIME string
42 Digest string
43 CSP map[string][]string
44 }
45
46 // appInstanceRegistry is the host's bounded set of live App instances. Max 32:
47 // beyond that the oldest instance is reclaimed, so a runaway App cannot pin
48 // memory. Server disconnect reclaims every instance of that server.
49 type appInstanceRegistry struct {
50 mu sync.Mutex
51 instances map[string]*AppInstance
52 order []string
53 bytes int
54 }
55
56 const (
57 maxAppInstances = 32
58 maxAppResourceSnapshotBytes = 4 << 20
59 maxAppResourceRegistryBytes = 16 << 20
60 )
61
62 func newAppInstanceRegistry() *appInstanceRegistry {
63 return &appInstanceRegistry{instances: map[string]*AppInstance{}}
64 }
65
66 func (r *appInstanceRegistry) newToken() string {
67 b := make([]byte, 24)
68 if _, err := rand.Read(b); err != nil {
69 // crypto/rand failure is fatal-grade; an App token must be unguessable.
70 panic("plugin: app instance token entropy unavailable: " + err.Error())
71 }
72 return hex.EncodeToString(b)
73 }
74
75 // Register creates and stores a new instance, evicting the oldest when the
76 // registry is full.
77 func (r *appInstanceRegistry) Register(server, tool string, generation uint64, callID, resourceURI string) *AppInstance {
78 r.mu.Lock()
79 defer r.mu.Unlock()
80 callCtx, cancelCalls := context.WithCancel(context.Background())
81 inst := &AppInstance{
82 Token: r.newToken(), Server: server, Tool: tool,
83 Generation: generation, CallID: callID, ResourceURI: resourceURI,
84 callCtx: callCtx, cancelCalls: cancelCalls,
85 }
86 r.instances[inst.Token] = inst
87 r.order = append(r.order, inst.Token)
88 for len(r.order) > maxAppInstances {
89 r.releaseOldestLocked()
90 }
91 return cloneAppInstance(inst)
92 }
93
94 // Lookup resolves a token to its live instance.
95 func (r *appInstanceRegistry) Lookup(token string) (*AppInstance, bool) {
96 r.mu.Lock()
97 defer r.mu.Unlock()
98 inst, ok := r.instances[token]
99 return cloneAppInstance(inst), ok
100 }
101
102 func cloneAppInstance(inst *AppInstance) *AppInstance {
103 if inst == nil {
104 return nil
105 }
106 copy := *inst
107 copy.resourceCSP = cloneAppCSP(inst.resourceCSP)
108 copy.callCtx = nil
109 copy.cancelCalls = nil
110 return &copy
111 }
112
113 func cloneAppCSP(csp map[string][]string) map[string][]string {
114 if len(csp) == 0 {
115 return nil
116 }
117 out := make(map[string][]string, len(csp))
118 for directive, values := range csp {
119 out[directive] = append([]string(nil), values...)
120 }
121 return out
122 }
123
124 func (r *appInstanceRegistry) releaseOldestLocked() {
125 if len(r.order) == 0 {
126 return
127 }
128 oldest := r.order[0]
129 r.order = r.order[1:]
130 if inst := r.instances[oldest]; inst != nil {
131 r.bytes -= inst.resourceBytes
132 inst.cancelCalls()
133 }
134 delete(r.instances, oldest)
135 }
136
137 // BindResource freezes the validated UI resource and CSP onto an instance.
138 // The registry has a process-memory budget in addition to its instance-count
139 // bound; oldest instances are reclaimed before the new snapshot is exposed.
140 func (r *appInstanceRegistry) BindResource(token, content, mime, digest string, csp map[string][]string) bool {
141 resourceBytes := len(content) + appCSPBytes(csp)
142 if resourceBytes > maxAppResourceSnapshotBytes {
143 return false
144 }
145 r.mu.Lock()
146 defer r.mu.Unlock()
147 inst, ok := r.instances[token]
148 if !ok {
149 return false
150 }
151 r.bytes -= inst.resourceBytes
152 inst.resourceContent = content
153 inst.resourceMIME = mime
154 inst.resourceDigest = digest
155 inst.resourceCSP = cloneAppCSP(csp)
156 inst.resourceBytes = resourceBytes
157 r.bytes += resourceBytes
158 for r.bytes > maxAppResourceRegistryBytes && len(r.order) > 1 {
159 r.releaseOldestLocked()
160 }
161 _, ok = r.instances[token]
162 return ok && r.bytes <= maxAppResourceRegistryBytes
163 }
164
165 func appCSPBytes(csp map[string][]string) int {
166 size := 0
167 for directive, values := range csp {
168 size += len(directive)
169 for _, value := range values {
170 size += len(value)
171 }
172 }
173 return size
174 }
175
176 func (r *appInstanceRegistry) Resource(token string) (AppResourceSnapshot, bool) {
177 r.mu.Lock()
178 defer r.mu.Unlock()
179 inst, ok := r.instances[token]
180 if !ok || inst.resourceDigest == "" {
181 return AppResourceSnapshot{}, false
182 }
183 return AppResourceSnapshot{
184 Content: inst.resourceContent,
185 MIME: inst.resourceMIME,
186 Digest: inst.resourceDigest,
187 CSP: cloneAppCSP(inst.resourceCSP),
188 }, true
189 }
190
191 func (r *appInstanceRegistry) Context(token string) (context.Context, bool) {
192 r.mu.Lock()
193 defer r.mu.Unlock()
194 inst, ok := r.instances[token]
195 if !ok || inst.callCtx == nil {
196 return nil, false
197 }
198 return inst.callCtx, true
199 }
200
201 // Release drops one instance (tab closed, component unmounted).
202 func (r *appInstanceRegistry) Release(token string) {
203 r.mu.Lock()
204 defer r.mu.Unlock()
205 if _, ok := r.instances[token]; !ok {
206 return
207 }
208 r.bytes -= r.instances[token].resourceBytes
209 r.instances[token].cancelCalls()
210 delete(r.instances, token)
211 for i, t := range r.order {
212 if t == token {
213 r.order = append(r.order[:i], r.order[i+1:]...)
214 break
215 }
216 }
217 }
218
219 // ReleaseServer drops every instance of one server (disconnect path).
220 func (r *appInstanceRegistry) ReleaseServer(server string) {
221 r.mu.Lock()
222 defer r.mu.Unlock()
223 for token, inst := range r.instances {
224 if inst.Server == server {
225 r.bytes -= inst.resourceBytes
226 inst.cancelCalls()
227 delete(r.instances, token)
228 }
229 }
230 filtered := r.order[:0]
231 for _, t := range r.order {
232 if _, ok := r.instances[t]; ok {
233 filtered = append(filtered, t)
234 }
235 }
236 r.order = filtered
237 }
238
239 // Len reports the live instance count.
240 func (r *appInstanceRegistry) Len() int {
241 r.mu.Lock()
242 defer r.mu.Unlock()
243 return len(r.instances)
244 }
245
246 // RegisterAppInstance creates a live App instance on the host.
247 func (h *Host) RegisterAppInstance(server, tool string, generation uint64, callID, resourceURI string) *AppInstance {
248 return h.appInstances.Register(server, tool, generation, callID, resourceURI)
249 }
250
251 // LookupAppInstance resolves a token against the host registry.
252 func (h *Host) LookupAppInstance(token string) (*AppInstance, bool) {
253 return h.appInstances.Lookup(token)
254 }
255
256 // AppInstanceContext is cancelled when the App closes, is evicted, or its
257 // server disconnects. App-initiated calls use it as their lifetime owner.
258 func (h *Host) AppInstanceContext(token string) (context.Context, bool) {
259 return h.appInstances.Context(token)
260 }
261
262 // BindAppResource freezes one validated resource onto the live instance.
263 func (h *Host) BindAppResource(token, content, mime, digest string, csp map[string][]string) bool {
264 return h.appInstances.BindResource(token, content, mime, digest, csp)
265 }
266
267 // AppResource resolves the immutable resource snapshot for a live instance.
268 func (h *Host) AppResource(token string) (AppResourceSnapshot, bool) {
269 return h.appInstances.Resource(token)
270 }
271
272 // AppInstanceResourceDescriptor validates that the originating tool still
273 // belongs to the same server/catalog generation and still declares the exact
274 // ui:// resource before Desktop reads or serves it.
275 func (h *Host) AppInstanceResourceDescriptor(token string) (map[string][]string, bool) {
276 inst, ok := h.LookupAppInstance(token)
277 if !ok {
278 return nil, false
279 }
280 h.mu.RLock()
281 var client *Client
282 for _, c := range h.clients {
283 if c.name == inst.Server && !c.closed.Load() {
284 client = c
285 break
286 }
287 }
288 h.mu.RUnlock()
289 if client == nil {
290 return nil, false
291 }
292 if !client.appsNegotiated() {
293 return nil, false
294 }
295 client.toolsMu.RLock()
296 defer client.toolsMu.RUnlock()
297 if client.toolCatalog.generation != inst.Generation || client.toolCatalogStale() {
298 return nil, false
299 }
300 for _, candidates := range [][]tool.Tool{client.toolCatalog.adapters, client.toolCatalog.appAdapters} {
301 for _, candidate := range candidates {
302 rt, ok := candidate.(*remoteTool)
303 if ok && rt.rawName == inst.Tool && rt.appCallable && rt.uiResourceURI == inst.ResourceURI {
304 return cloneAppCSP(rt.uiCSP), true
305 }
306 }
307 }
308 return nil, false
309 }
310
311 // ReleaseAppInstance drops one instance.
312 func (h *Host) ReleaseAppInstance(token string) {
313 h.appInstances.Release(token)
314 }
315
316 // AppInstanceTool resolves the App-callable tool an instance may invoke:
317 // same server, visibility includes "app", catalog generation unchanged.
318 func (h *Host) AppInstanceTool(token, rawToolName string) (toolRef, bool) {
319 inst, ok := h.LookupAppInstance(token)
320 if !ok {
321 return toolRef{}, false
322 }
323 h.mu.RLock()
324 var client *Client
325 for _, c := range h.clients {
326 if c.name == inst.Server && !c.closed.Load() {
327 client = c
328 break
329 }
330 }
331 h.mu.RUnlock()
332 if client == nil {
333 return toolRef{}, false
334 }
335 client.toolsMu.RLock()
336 defer client.toolsMu.RUnlock()
337 if client.toolCatalog.generation != inst.Generation || client.toolCatalogStale() {
338 return toolRef{}, false
339 }
340 for _, t := range client.toolCatalog.appAdapters {
341 rt, ok := t.(*remoteTool)
342 if ok && rt.rawName == rawToolName && rt.appCallable {
343 return toolRef{server: inst.Server, tool: rt}, true
344 }
345 }
346 return toolRef{}, false
347 }
348
349 type toolRef struct {
350 server string
351 tool *remoteTool
352 }
353
354 // UITool exposes the App-callable tool for CSP assembly.
355 func (r toolRef) UITool() *remoteTool { return r.tool }
356
357 // ReadResourceForApp reads one ui resource for the Apps channel, returning the
358 // text content, declared mime type, and resource-level Apps CSP metadata.
359 func (h *Host) ReadResourceForApp(ctx context.Context, server, uri string) (string, string, map[string][]string, error) {
360 h.mu.RLock()
361 var client *Client
362 for _, c := range h.clients {
363 if c.name == server && !c.closed.Load() {
364 client = c
365 break
366 }
367 }
368 h.mu.RUnlock()
369 if client == nil {
370 return "", "", nil, fmt.Errorf("server %q not connected", server)
371 }
372 if !client.appsNegotiated() {
373 return "", "", nil, fmt.Errorf("server %q did not negotiate the MCP Apps extension", server)
374 }
375 return client.readResourceWithMime(ctx, uri)
376 }
377
378 // readResourceWithMime reads one resource and returns its text, mime type, and
379 // the 2026 Apps resource `_meta.ui.csp` (flat `ui/csp` is accepted too).
380 func (c *Client) readResourceWithMime(ctx context.Context, uri string) (string, string, map[string][]string, error) {
381 res, err := c.call(ctx, "resources/read", map[string]any{"uri": uri})
382 if err != nil {
383 return "", "", nil, err
384 }
385 var wire struct {
386 Contents []struct {
387 URI string `json:"uri"`
388 MimeType string `json:"mimeType"`
389 Text string `json:"text"`
390 Meta struct {
391 UI *struct {
392 CSP map[string][]string `json:"csp,omitempty"`
393 } `json:"ui,omitempty"`
394 FlatCSP map[string][]string `json:"ui/csp,omitempty"`
395 } `json:"_meta,omitempty"`
396 } `json:"contents"`
397 }
398 if err := json.Unmarshal(res, &wire); err != nil {
399 return "", "", nil, err
400 }
401 if len(wire.Contents) == 0 {
402 return "", "", nil, fmt.Errorf("resource %q returned no contents", uri)
403 }
404 first := wire.Contents[0]
405 if first.URI != "" && first.URI != uri {
406 return "", "", nil, fmt.Errorf("resource %q returned mismatched URI %q", uri, first.URI)
407 }
408 csp := first.Meta.FlatCSP
409 if first.Meta.UI != nil && len(first.Meta.UI.CSP) > 0 {
410 csp = first.Meta.UI.CSP
411 }
412 return first.Text, first.MimeType, cloneAppCSP(csp), nil
413 }
414
414 lines GO