返回 DeepSeek-Reasonix
mcp_decision.go
根目录 / internal / config / mcp_decision.go
1 package config
2
3 import (
4 "crypto/sha256"
5 "encoding/hex"
6 "encoding/json"
7 "io"
8 "os"
9 "path/filepath"
10 "sort"
11 "strconv"
12 "strings"
13 )
14
15 // MCPDecision is a server's resolved enable state. Pending and Changed are both
16 // off, but for a reason the user can act on: a project declared the server and
17 // nobody approved this declaration of it.
18 type MCPDecision int
19
20 const (
21 MCPDecisionOff MCPDecision = iota
22 MCPDecisionOn
23 MCPDecisionPending
24 MCPDecisionChanged
25 )
26
27 // Code is the stable identity of d for listings and the model.
28 func (d MCPDecision) Code() string {
29 switch d {
30 case MCPDecisionOn:
31 return "enabled"
32 case MCPDecisionPending:
33 return "awaiting_user_decision"
34 case MCPDecisionChanged:
35 return "changed_since_enabled"
36 default:
37 return "disabled"
38 }
39 }
40
41 // AwaitsUser reports a project-declared server the user has not approved in its
42 // current form.
43 func (d MCPDecision) AwaitsUser() bool {
44 return d == MCPDecisionPending || d == MCPDecisionChanged
45 }
46
47 // RepositoryDeclared reports an entry whose file arrives with the workspace:
48 // project reasonix.toml, .mcp.json, or unknown provenance.
49 func RepositoryDeclared(entry PluginEntry) bool {
50 scope, _, _, _ := activationIdentity(entry, "")
51 return scope == MCPActivationWorkspace
52 }
53
54 // DeclaredDefaultOn is a server's state when no decision is recorded or the
55 // store cannot be read. Project configuration arrives with a checkout, so it
56 // cannot choose commands or endpoints the host starts without the user.
57 func DeclaredDefaultOn(entry PluginEntry) bool {
58 return !RepositoryDeclared(entry) && entry.ShouldAutoStart()
59 }
60
61 func undecided(entry PluginEntry) MCPDecision {
62 switch {
63 case DeclaredDefaultOn(entry):
64 return MCPDecisionOn
65 case RepositoryDeclared(entry) && entry.ShouldAutoStart():
66 return MCPDecisionPending
67 }
68 return MCPDecisionOff
69 }
70
71 // Decision resolves entry in workspace. A recorded decision for a
72 // project-declared server holds only for the declaration it was made on.
73 func (s *MCPActivationStore) Decision(entry PluginEntry, workspace string) (MCPDecision, error) {
74 scope, workspaceFP, source, owner := ActivationIdentity(entry, workspace)
75 if s == nil {
76 return undecided(entry), nil
77 }
78 row, found, err := s.lookupRow(MCPActivationOverride{
79 Scope: scope, Workspace: workspaceFP, Source: source, Owner: owner, Server: entry.Name,
80 })
81 switch {
82 case err != nil:
83 return undecided(entry), err
84 case !found:
85 return undecided(entry), nil
86 case !row.Enabled:
87 return MCPDecisionOff, nil
88 case scope == MCPActivationWorkspace && row.Identity != projectDeclarationDigest(entry, workspace):
89 return MCPDecisionChanged, nil
90 }
91 return MCPDecisionOn, nil
92 }
93
94 // IsEnabled reports whether entry may start in workspace.
95 func (s *MCPActivationStore) IsEnabled(entry PluginEntry, workspace string) (bool, error) {
96 d, err := s.Decision(entry, workspace)
97 if err != nil {
98 return false, err
99 }
100 return d == MCPDecisionOn, nil
101 }
102
103 // MCPServerDecision resolves entry against the default store; an unreadable
104 // store leaves a project-declared server awaiting the user.
105 func MCPServerDecision(entry PluginEntry, workspace string) MCPDecision {
106 d, _ := DefaultMCPActivationStore().Decision(entry, workspace)
107 return d
108 }
109
110 // MCPServerEnabled resolves entry against the default store, failing closed.
111 func MCPServerEnabled(entry PluginEntry, workspace string) bool {
112 return MCPServerDecision(entry, workspace) == MCPDecisionOn
113 }
114
115 // RecordExplicitStart records the user's approval when they start a
116 // project-declared server by hand, so the choice outlives the session.
117 func RecordExplicitStart(entry PluginEntry, workspace string) error {
118 if !RepositoryDeclared(entry) || MCPServerDecision(entry, workspace) == MCPDecisionOn {
119 return nil
120 }
121 return DefaultMCPActivationStore().SetServerEnabled(entry, workspace, true)
122 }
123
124 // MCPLaunchLine renders what a server would run, for a person deciding on it.
125 // Every env key is named; values show for launchEnvShown, whose keys change
126 // which code a process loads. The full declaration is `reasonix mcp get`.
127 func MCPLaunchLine(entry PluginEntry) string {
128 var parts []string
129 for _, k := range sortedKeys(entry.Env) {
130 if launchEnvShown(k) {
131 parts = append(parts, k+"="+strconv.Quote(entry.Env[k]))
132 } else {
133 parts = append(parts, k+"=…")
134 }
135 }
136 if t := strings.ToLower(strings.TrimSpace(entry.Type)); t != "" && t != "stdio" {
137 parts = append(parts, t, RedactMCPURL(entry.URL))
138 if len(entry.Headers) > 0 {
139 parts = append(parts, "headers:"+strings.Join(sortedKeys(entry.Headers), ","))
140 }
141 return strings.Join(parts, " ")
142 }
143 return strings.TrimSpace(strings.Join(append(append(parts, entry.Command), entry.Args...), " "))
144 }
145
146 // launchEnvShown is a security allow-list: loader and interpreter variables
147 // that make a process load code other than its command, and the package
148 // indexes npx, uvx and pip fetch that code from. Keys compare case-insensitively.
149 func launchEnvShown(key string) bool {
150 k := strings.ToUpper(strings.TrimSpace(key))
151 switch k {
152 case "PATH", "NODE_OPTIONS", "NODE_PATH", "PYTHONPATH", "PYTHONSTARTUP", "PYTHONHOME",
153 "LD_PRELOAD", "LD_LIBRARY_PATH", "RUBYOPT", "RUBYLIB", "PERL5OPT", "PERL5LIB",
154 "BASH_ENV", "ENV", "JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "CLASSPATH",
155 "PERLLIB", "PERL5DB", "ELECTRON_RUN_AS_NODE", "LUA_INIT",
156 "NPM_CONFIG_REGISTRY", "UV_INDEX_URL", "UV_EXTRA_INDEX_URL", "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL":
157 return true
158 }
159 return strings.HasPrefix(k, "DYLD_")
160 }
161
162 func sortedKeys(m map[string]string) []string {
163 keys := make([]string, 0, len(m))
164 for k := range m {
165 keys = append(keys, k)
166 }
167 sort.Strings(keys)
168 return keys
169 }
170
171 // projectDeclarationDigest covers everything a project file controls about a
172 // launch: the declaration as written, and the inputs it draws from the
173 // workspace (see declarationInputs).
174 func projectDeclarationDigest(entry PluginEntry, workspace string) string {
175 in := declarationInputsOf(entry, workspace)
176 payload, _ := json.Marshal(struct {
177 Declaration json.RawMessage
178 DotEnv, Files map[string]string
179 }{declarationText(entry), in.DotEnv, in.Files})
180 sum := sha256.Sum256(payload)
181 return hex.EncodeToString(sum[:])
182 }
183
184 // declarationText is the declaration as written in the project file.
185 func declarationText(entry PluginEntry) json.RawMessage {
186 text, _ := json.Marshal(struct {
187 Type, Command, URL string
188 Args []string
189 Env, Headers map[string]string
190 }{
191 strings.ToLower(strings.TrimSpace(entry.Type)), entry.Command, entry.URL,
192 nonEmpty(entry.Args), nonEmptyMap(entry.Env), nonEmptyMap(entry.Headers),
193 })
194 return text
195 }
196
197 // declarationInputs is what a declaration draws from the workspace: the project
198 // .env values it expands and the content of workspace files it names directly
199 // as command or argument.
200 type declarationInputs struct {
201 DotEnv, Files map[string]string
202 }
203
204 func declarationInputsOf(entry PluginEntry, workspace string) declarationInputs {
205 in := declarationInputs{DotEnv: map[string]string{}, Files: map[string]string{}}
206 record := func(name string) (string, bool) {
207 v, ok := entry.expansionEnv[name]
208 in.DotEnv[name] = v
209 return v, ok
210 }
211 fields := append([]string{entry.Command, entry.URL}, entry.Args...)
212 for _, v := range entry.Env {
213 fields = append(fields, v)
214 }
215 for _, v := range entry.Headers {
216 fields = append(fields, v)
217 }
218 for _, f := range fields {
219 expandVarsWithLookup(f, record)
220 }
221 expanded := entry.ExpandedPlugin()
222 for _, candidate := range append([]string{expanded.Command}, expanded.Args...) {
223 if path, sum := workspaceFileDigest(workspace, candidate); sum != "" {
224 in.Files[path] = sum
225 }
226 }
227 return in
228 }
229
230 // within reports whether every input in reads the same as it did in before.
231 func (in declarationInputs) within(before declarationInputs, beforeDotEnv map[string]string) bool {
232 for name, v := range in.DotEnv {
233 if beforeDotEnv[name] != v {
234 return false
235 }
236 }
237 for path, sum := range in.Files {
238 if before.Files[path] != sum {
239 return false
240 }
241 }
242 return true
243 }
244
245 // nonEmpty and nonEmptyMap fold an empty collection into nil, so a declaration
246 // digests the same whichever way a file round-trip spells "none".
247 func nonEmpty(s []string) []string {
248 if len(s) == 0 {
249 return nil
250 }
251 return s
252 }
253
254 func nonEmptyMap(m map[string]string) map[string]string {
255 if len(m) == 0 {
256 return nil
257 }
258 return m
259 }
260
261 func workspaceFileDigest(workspace, candidate string) (string, string) {
262 candidate = strings.TrimSpace(candidate)
263 if candidate == "" || strings.TrimSpace(workspace) == "" {
264 return "", ""
265 }
266 path := candidate
267 if !filepath.IsAbs(path) {
268 path = filepath.Join(workspace, path)
269 }
270 rel, err := filepath.Rel(workspace, path)
271 if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
272 return "", ""
273 }
274 info, err := os.Stat(path)
275 if err != nil || !info.Mode().IsRegular() {
276 return "", ""
277 }
278 f, err := os.Open(path)
279 if err != nil {
280 return rel, "unreadable"
281 }
282 defer f.Close()
283 h := sha256.New()
284 if _, err := io.Copy(h, f); err != nil {
285 return rel, "unreadable"
286 }
287 return filepath.ToSlash(rel), hex.EncodeToString(h.Sum(nil))
288 }
289
290 // UpsertPluginKeepingDecision writes entry to its source file, keeping the
291 // user's decision on it; see KeepMCPDecisionAcross.
292 func UpsertPluginKeepingDecision(root string, entry PluginEntry) (string, error) {
293 var path string
294 err := KeepMCPDecisionAcross(root, entry.Name, func() (PluginEntry, error) {
295 var werr error
296 path, werr = UpsertPluginInSourceForRoot(root, entry)
297 written, _ := NormalizePluginCommandLine(entry)
298 return written, werr
299 })
300 return path, err
301 }
302
303 // KeepMCPDecisionAcross runs an edit Reasonix makes to server name's
304 // declaration at the user's request; write returns the declaration it wrote.
305 // A server the user had enabled stays enabled only if the file now holds that
306 // declaration and every workspace input it draws on reads as it did in the
307 // approved state before the edit.
308 func KeepMCPDecisionAcross(root, name string, write func() (PluginEntry, error)) error {
309 before, ok := loadedPlugin(root, name)
310 wasOn := ok && MCPServerEnabled(before, root)
311 var beforeInputs declarationInputs
312 if wasOn {
313 beforeInputs = declarationInputsOf(before, root)
314 }
315 written, err := write()
316 if err != nil || !wasOn {
317 return err
318 }
319 p, ok := loadedPlugin(root, name)
320 if !ok || !RepositoryDeclared(p) || string(declarationText(written)) != string(declarationText(p)) {
321 return nil
322 }
323 if !declarationInputsOf(p, root).within(beforeInputs, before.expansionEnv) {
324 return nil
325 }
326 return DefaultMCPActivationStore().SetServerEnabled(p, root, true)
327 }
328
329 func loadedPlugin(root, name string) (PluginEntry, bool) {
330 cfg, err := LoadForRootReadOnly(root)
331 if err != nil {
332 return PluginEntry{}, false
333 }
334 for _, p := range cfg.Plugins {
335 if p.Name == name {
336 return p, true
337 }
338 }
339 return PluginEntry{}, false
340 }
341
341 lines GO