返回 DeepSeek-Reasonix
mcp_apps_app.go
根目录 / desktop / mcp_apps_app.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/url"
8 "strings"
9
10 "reasonix/internal/control"
11 "reasonix/internal/plugin"
12 )
13
14 // MCPAppInstanceView describes one live App surface for the frontend.
15 type MCPAppInstanceView struct {
16 InstanceToken string `json:"instanceToken"`
17 TabID string `json:"tabId"`
18 Server string `json:"server"`
19 Tool string `json:"tool"`
20 OuterURL string `json:"outerUrl"`
21 ResourceQuery string `json:"resourceQuery"`
22 ResourceDigest string `json:"resourceDigest"`
23 }
24
25 func (a *App) mcpRuntimeForTab(tabID string) (*WorkspaceTab, control.SessionAPI, *plugin.Host, error) {
26 tab, ctrl := a.tabAndCtrlByID(tabID)
27 if tab == nil || ctrl == nil {
28 return nil, nil, nil, fmt.Errorf("MCP runtime tab is unavailable")
29 }
30 hoster, ok := ctrl.(interface{ Host() *plugin.Host })
31 if !ok || hoster.Host() == nil {
32 return nil, nil, nil, fmt.Errorf("tab does not have an MCP runtime")
33 }
34 return tab, ctrl, hoster.Host(), nil
35 }
36
37 // MCPOpenAppInstanceForTab binds the App to the tab/controller that produced
38 // its tool result. The resource is read once, validated against the current
39 // catalog, fingerprinted, and frozen before any iframe can load it.
40 func (a *App) MCPOpenAppInstanceForTab(tabID, server, tool string, generation uint64, callID, resourceURI string) (*MCPAppInstanceView, error) {
41 tab, ctrl, host, err := a.mcpRuntimeForTab(tabID)
42 if err != nil {
43 return nil, err
44 }
45 if !strings.HasPrefix(resourceURI, "ui://") {
46 return nil, fmt.Errorf("invalid MCP App resource URI")
47 }
48 inst := host.RegisterAppInstance(server, tool, generation, callID, resourceURI)
49 release := func() { host.ReleaseAppInstance(inst.Token) }
50 csp, ok := host.AppInstanceResourceDescriptor(inst.Token)
51 if !ok {
52 release()
53 return nil, fmt.Errorf("MCP App resource no longer matches the current tool catalog")
54 }
55 readCtx, cancel := context.WithTimeout(a.bootContext(), appResourceReadTimeout)
56 defer cancel()
57 content, mime, resourceCSP, err := host.ReadResourceForApp(readCtx, server, resourceURI)
58 if err != nil {
59 release()
60 return nil, fmt.Errorf("read MCP App resource: %w", err)
61 }
62 if len(content) > maxAppResourceBytes || !isAppHTMLMimeType(mime) {
63 release()
64 return nil, fmt.Errorf("MCP App resource is unavailable or exceeds %d bytes", maxAppResourceBytes)
65 }
66 if len(resourceCSP) > 0 {
67 csp = resourceCSP
68 }
69 digest := resourceDigest(content)
70 if !host.BindAppResource(inst.Token, content, mime, digest, csp) {
71 release()
72 return nil, fmt.Errorf("MCP App instance expired before its resource was bound")
73 }
74 outer, err := a.appOriginURL(server)
75 if err != nil {
76 release()
77 return nil, err
78 }
79 a.mcpAppsSandbox.bind(inst.Token, mcpAppBinding{tabID: tab.ID, server: server, host: host, ctrl: ctrl})
80 return &MCPAppInstanceView{
81 InstanceToken: inst.Token,
82 TabID: tab.ID,
83 Server: server,
84 Tool: tool,
85 OuterURL: outer,
86 ResourceQuery: "/resource?token=" + inst.Token + "&digest=" + digest,
87 ResourceDigest: digest,
88 }, nil
89 }
90
91 // MCPAppResourceDigest returns the SHA-256 bound to a live instance's frozen
92 // resource snapshot.
93 func (a *App) MCPAppResourceDigest(instanceToken string) (string, error) {
94 binding, ok := a.mcpAppsSandbox.binding(instanceToken)
95 if !ok || binding.host == nil {
96 return "", fmt.Errorf("unknown app instance")
97 }
98 snapshot, ok := binding.host.AppResource(instanceToken)
99 if !ok {
100 return "", fmt.Errorf("unknown app instance")
101 }
102 return snapshot.Digest, nil
103 }
104
105 func (a *App) MCPAppResourceDigestForTab(tabID, instanceToken string) (string, error) {
106 binding, ok := a.mcpAppsSandbox.binding(instanceToken)
107 if !ok || binding.tabID != tabID {
108 return "", fmt.Errorf("app instance does not belong to tab")
109 }
110 return a.MCPAppResourceDigest(instanceToken)
111 }
112
113 func (a *App) MCPCloseAppInstanceForTab(tabID, instanceToken string) error {
114 binding, ok := a.mcpAppsSandbox.binding(instanceToken)
115 if !ok {
116 return nil
117 }
118 if binding.tabID != tabID {
119 return fmt.Errorf("app instance does not belong to tab")
120 }
121 a.mcpCloseAppInstance(instanceToken)
122 return nil
123 }
124
125 func validatedAppLink(rawURL string) (*url.URL, error) {
126 u, err := url.Parse(strings.TrimSpace(rawURL))
127 if err != nil || u.Host == "" || u.User != nil || (u.Scheme != "https" && u.Scheme != "http") {
128 return nil, fmt.Errorf("MCP App links must use a credential-free http(s) URL")
129 }
130 return u, nil
131 }
132
133 // MCPOpenAppLinkForTab independently validates the instance/tab binding and
134 // URL after the frontend's per-origin confirmation. No WebView-supplied grant
135 // can authorize file:, javascript:, credentials, or a cross-tab instance.
136 func (a *App) MCPOpenAppLinkForTab(tabID, instanceToken, rawURL string) error {
137 binding, ok := a.mcpAppsSandbox.binding(instanceToken)
138 if !ok || binding.tabID != tabID || binding.host == nil {
139 return fmt.Errorf("app instance does not belong to tab")
140 }
141 if _, ok := binding.host.LookupAppInstance(instanceToken); !ok {
142 return fmt.Errorf("app instance expired")
143 }
144 return a.mcpOpenAppLink(rawURL)
145 }
146
147 func (a *App) MCPAppCallToolForTab(tabID, instanceToken, toolName string, args json.RawMessage) (string, error) {
148 binding, ok := a.mcpAppsSandbox.binding(instanceToken)
149 if !ok || binding.tabID != tabID {
150 return "", fmt.Errorf("app instance does not belong to tab")
151 }
152 return a.mcpAppCallTool(instanceToken, toolName, args)
153 }
154
155 // mcpCloseAppInstance reclaims an instance (tab closed, component unmounted).
156 func (a *App) mcpCloseAppInstance(instanceToken string) {
157 if binding, ok := a.mcpAppsSandbox.release(instanceToken); ok && binding.host != nil {
158 binding.host.ReleaseAppInstance(instanceToken)
159 }
160 }
161
162 // mcpOpenAppLink opens a ui/open-link target after per-origin confirmation.
163 // The frontend asks first and shows the confirmation; this only routes to the
164 // system browser.
165 func (a *App) mcpOpenAppLink(rawURL string) error {
166 u, err := validatedAppLink(rawURL)
167 if err != nil {
168 return err
169 }
170 a.nativeHost().OpenExternal(a.ctx, u.String())
171 return nil
172 }
173
174 // mcpAppCallTool routes an App-initiated tools/call through the controller's
175 // gated channel: same server as the instance, visibility includes "app",
176 // catalog generation unchanged, and the ordinary permission policy decides.
177 func (a *App) mcpAppCallTool(instanceToken, toolName string, args json.RawMessage) (string, error) {
178 binding, ok := a.mcpAppsSandbox.binding(instanceToken)
179 if !ok || binding.ctrl == nil {
180 return "", fmt.Errorf("unknown app instance")
181 }
182 caller, ok := binding.ctrl.(interface {
183 mcpAppCallTool(instanceToken, toolName string, args json.RawMessage) (string, error)
184 })
185 if !ok {
186 return "", fmt.Errorf("runtime does not support app tool calls")
187 }
188 return caller.mcpAppCallTool(instanceToken, toolName, args)
189 }
190
190 lines GO