返回 DeepSeek-Reasonix
mcp_activation.go
根目录 / internal / config / mcp_activation.go
1 package config
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "time"
11
12 "reasonix/internal/filelock"
13 "reasonix/internal/fileutil"
14 "reasonix/internal/mcplaunch"
15 )
16
17 // MCP activation is the durable enable/disable switch for installed servers.
18 // Install remains the authorization action; this file only records whether an
19 // already authorized server is currently enabled for the catalog.
20
21 const (
22 mcpActivationVersion = 1
23 mcpActivationFilename = "mcp-activation.json"
24 mcpActivationLockFile = ".mcp-activation.lock"
25 )
26
27 // MCPActivationScope identifies where an enable override applies.
28 type MCPActivationScope string
29
30 const (
31 MCPActivationGlobal MCPActivationScope = "global"
32 MCPActivationWorkspace MCPActivationScope = "workspace"
33 )
34
35 // MCPActivationOverride is one durable enable/disable decision.
36 type MCPActivationOverride struct {
37 Scope MCPActivationScope `json:"scope"`
38 Workspace string `json:"workspace,omitempty"`
39 Source string `json:"source,omitempty"`
40 Owner string `json:"owner,omitempty"`
41 Server string `json:"server"`
42 Enabled bool `json:"enabled"`
43 }
44
45 // MCPActivationFile is the on-disk shape of $REASONIX_HOME/mcp-activation.json.
46 type MCPActivationFile struct {
47 Version int `json:"version"`
48 Overrides []MCPActivationOverride `json:"overrides"`
49 }
50
51 // MCPActivationStore loads and persists MCP enable overrides.
52 type MCPActivationStore struct {
53 path string
54 mu sync.Mutex
55 }
56
57 // MCPActivationPath returns the durable activation file under Reasonix home.
58 func MCPActivationPath(reasonixHome string) string {
59 return filepath.Join(strings.TrimSpace(reasonixHome), mcpActivationFilename)
60 }
61
62 // NewMCPActivationStore opens the activation store for reasonixHome.
63 func NewMCPActivationStore(reasonixHome string) *MCPActivationStore {
64 return &MCPActivationStore{path: MCPActivationPath(reasonixHome)}
65 }
66
67 // DefaultMCPActivationStore uses the process Reasonix home.
68 func DefaultMCPActivationStore() *MCPActivationStore {
69 return NewMCPActivationStore(ReasonixHomeDir())
70 }
71
72 // Path returns the store file path.
73 func (s *MCPActivationStore) Path() string {
74 if s == nil {
75 return ""
76 }
77 return s.path
78 }
79
80 // Load reads the activation file. Missing files yield an empty store.
81 func (s *MCPActivationStore) Load() (MCPActivationFile, error) {
82 if s == nil || strings.TrimSpace(s.path) == "" {
83 return MCPActivationFile{Version: mcpActivationVersion}, nil
84 }
85 s.mu.Lock()
86 defer s.mu.Unlock()
87 return s.loadLocked()
88 }
89
90 func (s *MCPActivationStore) loadLocked() (MCPActivationFile, error) {
91 data, err := os.ReadFile(s.path)
92 if err != nil {
93 if os.IsNotExist(err) {
94 return MCPActivationFile{Version: mcpActivationVersion}, nil
95 }
96 return MCPActivationFile{}, err
97 }
98 var file MCPActivationFile
99 if err := json.Unmarshal(data, &file); err != nil {
100 return MCPActivationFile{}, err
101 }
102 if file.Version == 0 {
103 file.Version = mcpActivationVersion
104 }
105 file.Overrides = compactActivationOverrides(file.Overrides)
106 return file, nil
107 }
108
109 // SetEnabled records a durable enable/disable override for one server.
110 func (s *MCPActivationStore) SetEnabled(override MCPActivationOverride) error {
111 if s == nil {
112 return nil
113 }
114 override = normalizeActivationOverride(override)
115 if override.Server == "" {
116 return nil
117 }
118 s.mu.Lock()
119 defer s.mu.Unlock()
120 unlockFile, err := s.lockUpdates()
121 if err != nil {
122 return err
123 }
124 defer unlockFile()
125 file, err := s.loadLocked()
126 if err != nil {
127 return err
128 }
129 file.Version = mcpActivationVersion
130 file.Overrides = upsertActivationOverride(file.Overrides, override)
131 return s.saveLocked(file)
132 }
133
134 // Clear removes the override for one server identity, restoring default enable.
135 func (s *MCPActivationStore) Clear(override MCPActivationOverride) error {
136 if s == nil {
137 return nil
138 }
139 override = normalizeActivationOverride(override)
140 if override.Server == "" {
141 return nil
142 }
143 s.mu.Lock()
144 defer s.mu.Unlock()
145 unlockFile, err := s.lockUpdates()
146 if err != nil {
147 return err
148 }
149 defer unlockFile()
150 file, err := s.loadLocked()
151 if err != nil {
152 return err
153 }
154 kept := file.Overrides[:0]
155 for _, existing := range file.Overrides {
156 if activationKey(existing) == activationKey(override) {
157 continue
158 }
159 kept = append(kept, existing)
160 }
161 file.Overrides = kept
162 file.Version = mcpActivationVersion
163 return s.saveLocked(file)
164 }
165
166 // Lookup reports whether an override exists and its enabled value.
167 func (s *MCPActivationStore) Lookup(scope MCPActivationScope, workspace, source, owner, server string) (enabled bool, found bool, err error) {
168 file, err := s.Load()
169 if err != nil {
170 return false, false, err
171 }
172 want := activationKey(normalizeActivationOverride(MCPActivationOverride{
173 Scope: scope,
174 Workspace: workspace,
175 Source: source,
176 Owner: owner,
177 Server: server,
178 }))
179 for _, existing := range file.Overrides {
180 if activationKey(existing) == want {
181 return existing.Enabled, true, nil
182 }
183 }
184 return false, false, nil
185 }
186
187 // IsEnabled resolves the product enable state for one plugin entry.
188 // An explicit activation override wins; otherwise auto_start=false maps to
189 // disabled and true/nil map to enabled.
190 func (s *MCPActivationStore) IsEnabled(entry PluginEntry, workspace string) (bool, error) {
191 scope, workspaceFP, source, owner := ActivationIdentity(entry, workspace)
192 if s != nil {
193 if enabled, found, err := s.Lookup(scope, workspaceFP, source, owner, entry.Name); err != nil {
194 return false, err
195 } else if found {
196 return enabled, nil
197 }
198 }
199 return entry.ShouldAutoStart(), nil
200 }
201
202 // SetServerEnabled records a durable enable/disable override for entry.
203 func (s *MCPActivationStore) SetServerEnabled(entry PluginEntry, workspace string, enabled bool) error {
204 scope, workspaceFP, source, owner := ActivationIdentity(entry, workspace)
205 return s.SetEnabled(MCPActivationOverride{
206 Scope: scope,
207 Workspace: workspaceFP,
208 Source: source,
209 Owner: owner,
210 Server: entry.Name,
211 Enabled: enabled,
212 })
213 }
214
215 // ClearServer removes the activation override for entry, restoring defaults.
216 func (s *MCPActivationStore) ClearServer(entry PluginEntry, workspace string) error {
217 scope, workspaceFP, source, owner := ActivationIdentity(entry, workspace)
218 return s.Clear(MCPActivationOverride{
219 Scope: scope,
220 Workspace: workspaceFP,
221 Source: source,
222 Owner: owner,
223 Server: entry.Name,
224 })
225 }
226
227 // ActivationIdentity returns the durable key components for one plugin entry.
228 func ActivationIdentity(entry PluginEntry, workspace string) (scope MCPActivationScope, workspaceFP, source, owner string) {
229 return activationIdentity(entry, workspace)
230 }
231
232 func (s *MCPActivationStore) saveLocked(file MCPActivationFile) error {
233 if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
234 return err
235 }
236 data, err := json.MarshalIndent(file, "", " ")
237 if err != nil {
238 return err
239 }
240 data = append(data, '\n')
241 return fileutil.AtomicWriteFile(s.path, data, 0o600)
242 }
243
244 // lockUpdates serializes the full read-modify-write transaction across both
245 // independent store instances and separate Reasonix processes. Atomic rename
246 // prevents torn JSON; this lock additionally prevents the last writer from
247 // silently dropping another server's override.
248 func (s *MCPActivationStore) lockUpdates() (func(), error) {
249 dir := filepath.Dir(s.path)
250 if err := os.MkdirAll(dir, 0o700); err != nil {
251 return nil, err
252 }
253 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
254 defer cancel()
255 unlock, err := filelock.Acquire(ctx, filepath.Join(dir, mcpActivationLockFile))
256 if err != nil {
257 return nil, err
258 }
259 return unlock, nil
260 }
261
262 func activationIdentity(entry PluginEntry, workspace string) (MCPActivationScope, string, string, string) {
263 source := strings.TrimSpace(string(entry.Source))
264 owner := ""
265 if entry.Source == MCPSourcePluginPackage {
266 // Plugin-package servers key by owner+server to avoid collisions when
267 // two packages expose the same short server name. Owner is filled by
268 // the caller when known; Source alone still disambiguates packages.
269 owner = strings.TrimSpace(source)
270 return MCPActivationGlobal, "", source, owner
271 }
272 if entry.Source.ProjectScoped() || source == "workspace_config" || source == "project" || source == ".mcp.json" {
273 return MCPActivationWorkspace, mcplaunch.WorkspaceFingerprint(workspace), source, owner
274 }
275 return MCPActivationGlobal, "", source, owner
276 }
277
278 func normalizeActivationOverride(o MCPActivationOverride) MCPActivationOverride {
279 o.Server = strings.TrimSpace(o.Server)
280 o.Source = strings.TrimSpace(o.Source)
281 o.Owner = strings.TrimSpace(o.Owner)
282 o.Workspace = strings.TrimSpace(o.Workspace)
283 switch o.Scope {
284 case MCPActivationWorkspace:
285 // keep
286 default:
287 o.Scope = MCPActivationGlobal
288 o.Workspace = ""
289 }
290 return o
291 }
292
293 func activationKey(o MCPActivationOverride) string {
294 o = normalizeActivationOverride(o)
295 return strings.Join([]string{string(o.Scope), o.Workspace, o.Source, o.Owner, o.Server}, "\x00")
296 }
297
298 func upsertActivationOverride(overrides []MCPActivationOverride, next MCPActivationOverride) []MCPActivationOverride {
299 next = normalizeActivationOverride(next)
300 key := activationKey(next)
301 for i, existing := range overrides {
302 if activationKey(existing) == key {
303 overrides[i] = next
304 return overrides
305 }
306 }
307 return append(overrides, next)
308 }
309
310 func compactActivationOverrides(overrides []MCPActivationOverride) []MCPActivationOverride {
311 if len(overrides) == 0 {
312 return nil
313 }
314 out := make([]MCPActivationOverride, 0, len(overrides))
315 seen := map[string]int{}
316 for _, o := range overrides {
317 o = normalizeActivationOverride(o)
318 if o.Server == "" {
319 continue
320 }
321 key := activationKey(o)
322 if idx, ok := seen[key]; ok {
323 out[idx] = o
324 continue
325 }
326 seen[key] = len(out)
327 out = append(out, o)
328 }
329 return out
330 }
331
331 lines GO