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