File size: 11,268 Bytes
5ed4ed6
397ffab
5ed4ed6
 
 
 
 
 
 
 
 
 
0829899
5ed4ed6
397ffab
 
 
 
 
 
f8991b5
 
 
397ffab
 
 
 
 
 
 
 
 
5ed4ed6
 
f8991b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397ffab
f8991b5
397ffab
 
 
f8991b5
397ffab
 
 
5ed4ed6
 
 
397ffab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8991b5
 
 
 
 
 
 
 
 
5ed4ed6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8991b5
 
5ed4ed6
397ffab
 
5ed4ed6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0829899
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8991b5
5ed4ed6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0829899
5ed4ed6
 
 
 
 
f8991b5
 
5ed4ed6
 
397ffab
5ed4ed6
 
f8991b5
 
 
5ed4ed6
 
 
397ffab
 
 
 
 
 
 
 
5ed4ed6
 
 
 
 
 
 
 
397ffab
5ed4ed6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397ffab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ed4ed6
 
 
 
 
 
 
 
 
 
 
abad816
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ed4ed6
abad816
5ed4ed6
 
abad816
 
 
 
 
 
 
 
 
5ed4ed6
 
 
 
 
abad816
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
#!/usr/bin/env node
// Makes Agent Web's MCP servers available, configured the same way, in every
// session. Claude Code reads MCP servers from ~/.claude.json: the top-level
// "mcpServers" (user scope) applies to every folder, while entries under
// "projects[<folder>].mcpServers" (local scope, the default of `claude mcp add`)
// exist only in that one folder -- which is why a server added in one session
// was missing in another. Runs at container start, before any Claude process,
// so nothing else is writing the file.
'use strict';

const fs = require('fs');
const path = require('path');
const { tempPathOf } = require(path.join(__dirname, 'temp-paths.js'));

// playwright: Google Chrome is installed in the image (see Dockerfile). The
// container has no user namespaces, so the browser runs without Chrome's own
// sandbox (Playwright MCP enables it by default for the chrome channel), and
// headless so it never depends on the virtual screen. --isolated keeps each
// session's browser profile in memory, so parallel sessions never fight over
// one profile directory.
//
// agentweb: Agent Web's own tools (share_file, virtual screen control).
//
// github: GitHub's remote MCP server. It has no OAuth client registration for
// other apps, so a sign-in from Claude Code can never complete; it takes a
// token instead. The headersHelper reads one from GH_TOKEN / GITHUB_TOKEN or
// from `gh auth login` each time Claude connects, so no token is written here.
// An entry that already authenticates on its own (its own Authorization
// header, helper or OAuth client) is kept as it is.
//
// `requires` names a file the entry needs; entries whose file is missing are
// left out.
const MANAGED = {
  playwright: {
    entry: {
      type: 'stdio',
      command: 'playwright-mcp',
      args: ['--browser=chrome', '--headless', '--isolated', '--no-sandbox'],
      env: {},
    },
    detect: /@playwright\/mcp|playwright-mcp|mcp-server-playwright/,
  },
  agentweb: {
    entry: {
      type: 'stdio',
      command: 'node',
      args: ['/app/scripts/agentweb-mcp.js'],
      env: {},
    },
    detect: /agentweb-mcp/,
    requires: '/app/scripts/agentweb-mcp.js',
  },
  github: {
    entry: {
      type: 'http',
      url: 'https://api.githubcopilot.com/mcp/',
      headersHelper: '/app/scripts/github-mcp-headers',
    },
    detect: /^https:\/\/api\.githubcopilot\.com\//,
    keep: authenticatesOnItsOwn,
    requires: '/app/scripts/github-mcp-headers',
  },
};

// Servers earlier versions added and no longer ship. Removed while they are
// still what was written (or point at a wrapper that no longer exists).
const RETIRED = {
  // Roblox Studio MCP (Roblox Studio through Vinegar is gone).
  robloxstudio: { brokenIf: (entry) => entry && entry.command === '/app/scripts/roblox-mcp' },
};

function authenticatesOnItsOwn(entry) {
  if (!entry || typeof entry !== 'object') return false;
  if (typeof entry.headersHelper === 'string' && entry.headersHelper) return true;
  if (entry.oauth && typeof entry.oauth === 'object' && entry.oauth.clientId) return true;
  const headers = entry.headers && typeof entry.headers === 'object' ? entry.headers : {};
  return Object.keys(headers).some((key) => /^authorization$/i.test(key));
}

function defaultManaged() {
  const out = {};
  for (const [name, spec] of Object.entries(MANAGED)) {
    if (spec.requires && !fs.existsSync(spec.requires)) continue;
    out[name] = spec;
  }
  return out;
}

function log(message) {
  console.log(`[setup-mcp] ${message}`);
}

// Key order must not matter when comparing entries Claude Code rewrote.
function canonical(value) {
  if (Array.isArray(value)) return value.map(canonical);
  if (value && typeof value === 'object') {
    const out = {};
    for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]);
    return out;
  }
  return value;
}

function same(a, b) {
  return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
}

// A hand-made entry for the same server (e.g. from `claude mcp add`).
function isSameServer(entry, detect) {
  if (!entry || typeof entry !== 'object') return false;
  const parts = [entry.command, entry.url, ...(Array.isArray(entry.args) ? entry.args : [])];
  return parts.some((part) => typeof part === 'string' && detect.test(part));
}

function readState(file) {
  try {
    const state = JSON.parse(fs.readFileSync(file, 'utf8'));
    if (state && typeof state.servers === 'object' && state.servers) return state;
  } catch {
    // ilk calisma
  }
  return { servers: {} };
}

function writeFileAtomic(file, text, mode) {
  const tmp = `${file}.agentweb-tmp`;
  fs.writeFileSync(tmp, text, { mode });
  fs.renameSync(tmp, file);
  fs.chmodSync(file, mode);
}

// MCP servers running from /tmp lose their files on every restart; say so in
// the log (the MCP screen offers a permanent install).
function reportTempServers(config) {
  const lists = [['tum oturumlar', config.mcpServers]];
  const projects = config.projects && typeof config.projects === 'object' ? config.projects : {};
  for (const [folder, project] of Object.entries(projects)) {
    if (project && project.mcpServers && typeof project.mcpServers === 'object') lists.push([folder, project.mcpServers]);
  }
  for (const [where, servers] of lists) {
    for (const [name, entry] of Object.entries(servers || {})) {
      const temp = tempPathOf(entry);
      if (!temp) continue;
      const gone = fs.existsSync(temp) ? '' : '; dosyalari silinmis';
      log(`UYARI: "${name}" (${where}) ${temp} icinden calisiyor: /tmp her yeniden baslatmada silinir${gone}. MCP ekranindaki "Kalici kur" ile Claude'a kalici klasore kurdurabilirsin.`);
    }
  }
}

function apply(home, managed = defaultManaged()) {
  const configFile = path.join(home, '.claude.json');
  const stateFile = path.join(home, '.cc-web', 'managed-mcp.json');

  let config = {};
  let mode = 0o600;
  if (fs.existsSync(configFile)) {
    try {
      config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
      mode = fs.statSync(configFile).mode & 0o777;
    } catch (err) {
      log(`~/.claude.json okunamadi (${err.message}); dokunulmadi.`);
      return;
    }
    if (!config || typeof config !== 'object' || Array.isArray(config)) {
      log('~/.claude.json beklenmeyen bicimde; dokunulmadi.');
      return;
    }
  }
  if (!config.mcpServers || typeof config.mcpServers !== 'object') config.mcpServers = {};
  reportTempServers(config);

  const state = readState(stateFile);
  let configChanged = false;
  let stateChanged = false;

  for (const [name, spec] of Object.entries(managed)) {
    const wanted = spec.entry;
    const current = config.mcpServers[name];
    const previous = state.servers[name];
    const keep = typeof spec.keep === 'function' ? spec.keep : () => false;

    if (previous === undefined) {
      // First run: take over the name. A hand-made entry for the same server
      // (user or per-folder scope) is replaced so every session behaves the same.
      if (current !== undefined && !isSameServer(current, spec.detect)) {
        log(`"${name}" adinda baska bir sunucu tanimli; dokunulmadi.`);
        continue;
      }
      if (current !== undefined && keep(current)) {
        // Works on its own (e.g. a token in its own header): leave it, and
        // from now on treat it as the user's.
        log(`"${name}" icin kendi ayarin korunuyor.`);
        state.servers[name] = { userOwned: true };
        stateChanged = true;
        continue;
      }
      if (!same(current, wanted)) {
        config.mcpServers[name] = wanted;
        configChanged = true;
        log(`"${name}" sunucusu tum oturumlar icin (kullanici kapsami) tanimlandi.`);
      }
      const projects = config.projects && typeof config.projects === 'object' ? config.projects : {};
      for (const [folder, project] of Object.entries(projects)) {
        const servers = project && project.mcpServers;
        if (servers && isSameServer(servers[name], spec.detect) && !keep(servers[name])) {
          delete servers[name];
          configChanged = true;
          log(`${folder} klasorune ozel eski "${name}" tanimi kaldirildi.`);
        }
      }
      state.servers[name] = wanted;
      stateChanged = true;
    } else if (current === undefined) {
      // Removed on purpose (claude mcp remove); respect that.
    } else if (same(current, previous)) {
      // Still exactly what we wrote last time: move it to the current default.
      if (!same(current, wanted)) {
        config.mcpServers[name] = wanted;
        configChanged = true;
        log(`"${name}" sunucusunun varsayilan ayarlari guncellendi.`);
      }
      if (!same(previous, wanted)) {
        state.servers[name] = wanted;
        stateChanged = true;
      }
    }
    // Otherwise the user customised the entry; it stays as it is.
  }

  for (const [name, spec] of Object.entries(RETIRED)) {
    const previous = state.servers[name];
    if (previous === undefined) continue;
    const current = config.mcpServers[name];
    if (current !== undefined) {
      if (same(current, previous) || spec.brokenIf(current)) {
        delete config.mcpServers[name];
        configChanged = true;
        log(`"${name}" sunucusu kaldirildi (artik Agent Web'de yok).`);
      } else {
        log(`"${name}" elle degistirilmis; dokunulmadi.`);
      }
    }
    delete state.servers[name];
    stateChanged = true;
  }

  if (configChanged) {
    const backup = `${configFile}.agentweb-backup`;
    if (fs.existsSync(configFile) && !fs.existsSync(backup)) fs.copyFileSync(configFile, backup);
    writeFileAtomic(configFile, `${JSON.stringify(config, null, 2)}\n`, mode);
  }
  if (stateChanged) {
    fs.mkdirSync(path.dirname(stateFile), { recursive: true });
    writeFileAtomic(stateFile, `${JSON.stringify(state, null, 2)}\n`, 0o600);
  }
}

// Brings back a ready-made server that was removed on purpose (from the MCP
// screen or with claude mcp remove):
//   node /app/scripts/setup-mcp.js --restore playwright
function restore(home, name, managed = defaultManaged()) {
  if (!Object.prototype.hasOwnProperty.call(managed, name)) {
    log(`"${name}" hazir sunuculardan biri degil (${Object.keys(managed).join(', ')}).`);
    return false;
  }
  const stateFile = path.join(home, '.cc-web', 'managed-mcp.json');
  const state = readState(stateFile);
  if (state.servers[name] !== undefined) {
    delete state.servers[name];
    fs.mkdirSync(path.dirname(stateFile), { recursive: true });
    writeFileAtomic(stateFile, `${JSON.stringify(state, null, 2)}\n`, 0o600);
  }
  apply(home, { [name]: managed[name] });
  return true;
}

if (require.main === module) {
  const args = process.argv.slice(2);
  if (!process.env.HOME) {
    log('HOME tanimli degil; atlandi.');
  } else if (args[0] === '--restore') {
    if (!args[1]) {
      log(`Kullanim: setup-mcp.js --restore <${Object.keys(defaultManaged()).join('|')}>`);
      process.exitCode = 2;
    } else if (restore(process.env.HOME, args[1])) {
      log("Agent Web'de MCP sunuculari ekranindaki \"Claude'u yeniden baslat\" ile yuklenir.");
    } else {
      process.exitCode = 1;
    }
  } else {
    apply(process.env.HOME);
  }
}

module.exports = { apply, restore, MANAGED, RETIRED };