copilot-vscode.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. /**
  2. * VS Code (GitHub Copilot Chat) target.
  3. *
  4. * - MCP server entry to `.vscode/mcp.json` (local, workspace-scoped)
  5. * or the user-level `mcp.json` in the VS Code User dir (global):
  6. *
  7. * macOS: ~/Library/Application Support/Code/User/mcp.json
  8. * Windows: %APPDATA%\Code\User\mcp.json
  9. * Linux: $XDG_CONFIG_HOME|~/.config/Code/User/mcp.json
  10. *
  11. * VS Code moved MCP config out of settings.json into this dedicated
  12. * `mcp.json` (v1.102, "MCP: Open User Configuration"). Shape is
  13. * `{ "servers": { "<name>": { "type": "stdio", "command", "args" } } }`
  14. * — note `servers`, not the `mcpServers` wrapper Claude/Cursor use.
  15. * - No instructions file: Copilot Chat consumes the MCP `initialize`
  16. * instructions, the single source of truth (#529).
  17. * - No permissions concept — `autoAllow` is silently ignored.
  18. *
  19. * ## Why `--path` only for local installs (NOT the Cursor pattern)
  20. *
  21. * Unlike Cursor, VS Code DOCUMENTS the launch cwd for stdio MCP
  22. * servers: "Working directory for the server command. Defaults to the
  23. * workspace folder when run in a workspace" (mcp-configuration
  24. * reference). The codegraph server resolves its project via the MCP
  25. * roots/list dance with a cwd fallback, so cwd alone is sufficient:
  26. *
  27. * - `local` install: absolute `--path` (known at install time) —
  28. * deterministic, and free of variables.
  29. * - `global` install: NO `--path`. Do not be tempted to pin it with
  30. * `${workspaceFolder}`: VS Code refuses to start a user-level
  31. * server whose entry uses that variable whenever a window has no
  32. * folder open (loose files, welcome tab), surfacing an error toast
  33. * "Variable workspaceFolder can not be resolved" in every such
  34. * window — exactly the error-noise that teaches users to disable
  35. * the server. With no `--path`, a folderless window still starts
  36. * the server fine and it serves the "no project" guidance.
  37. *
  38. * ## JSONC
  39. *
  40. * VS Code parses its config files as JSONC (comments + trailing commas
  41. * allowed), so reads + writes go through `jsonc-parser` — surgical
  42. * edits that preserve sibling servers, user comments, and formatting
  43. * across install / re-install / uninstall (same approach as opencode).
  44. */
  45. import * as fs from 'fs';
  46. import * as path from 'path';
  47. import * as os from 'os';
  48. import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser';
  49. import {
  50. AgentTarget,
  51. DetectionResult,
  52. InstallOptions,
  53. Location,
  54. WriteResult,
  55. } from './types';
  56. import {
  57. atomicWriteFileSync,
  58. getMcpServerConfig,
  59. jsonDeepEqual,
  60. } from './shared';
  61. function vscodeUserDir(): string {
  62. const home = os.homedir();
  63. if (process.platform === 'win32') {
  64. const appData = process.env.APPDATA && process.env.APPDATA.trim().length > 0
  65. ? process.env.APPDATA
  66. : path.join(home, 'AppData', 'Roaming');
  67. return path.join(appData, 'Code', 'User');
  68. }
  69. if (process.platform === 'darwin') {
  70. return path.join(home, 'Library', 'Application Support', 'Code', 'User');
  71. }
  72. const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0
  73. ? process.env.XDG_CONFIG_HOME
  74. : path.join(home, '.config');
  75. return path.join(xdg, 'Code', 'User');
  76. }
  77. function mcpJsonPath(loc: Location): string {
  78. return loc === 'global'
  79. ? path.join(vscodeUserDir(), 'mcp.json')
  80. : path.join(process.cwd(), '.vscode', 'mcp.json');
  81. }
  82. /**
  83. * Build the codegraph server entry for VS Code at the given location.
  84. * Local installs pin `--path`; global installs rely on VS Code's
  85. * documented workspace-folder cwd — see file header for why the global
  86. * entry must stay variable-free.
  87. */
  88. function buildVscodeServerEntry(loc: Location): { type: string; command: string; args: string[] } {
  89. const base = getMcpServerConfig();
  90. if (loc === 'local') {
  91. return { ...base, args: [...base.args, '--path', process.cwd()] };
  92. }
  93. return { ...base, args: [...base.args] };
  94. }
  95. function readConfigText(file: string): string {
  96. if (!fs.existsSync(file)) return '';
  97. return fs.readFileSync(file, 'utf-8');
  98. }
  99. function parseConfig(text: string): Record<string, any> {
  100. if (!text.trim()) return {};
  101. const errors: any[] = [];
  102. const result = parseJsonc(text, errors, { allowTrailingComma: true });
  103. if (result == null || typeof result !== 'object' || Array.isArray(result)) {
  104. return {};
  105. }
  106. return result as Record<string, any>;
  107. }
  108. const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' };
  109. class CopilotVscodeTarget implements AgentTarget {
  110. readonly id = 'copilot-vscode' as const;
  111. readonly displayName = 'VS Code (Copilot Chat)';
  112. readonly docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers';
  113. supportsLocation(_loc: Location): boolean {
  114. return true;
  115. }
  116. detect(loc: Location): DetectionResult {
  117. const file = mcpJsonPath(loc);
  118. const config = parseConfig(readConfigText(file));
  119. const alreadyConfigured = !!config.servers?.codegraph;
  120. // "Installed" heuristic: the VS Code User dir (created on first
  121. // launch) or ~/.vscode (extensions dir) for global; an existing
  122. // .vscode/ dir in the project for local.
  123. const installed = loc === 'global'
  124. ? fs.existsSync(vscodeUserDir()) || fs.existsSync(path.join(os.homedir(), '.vscode'))
  125. : fs.existsSync(path.join(process.cwd(), '.vscode'));
  126. return { installed, alreadyConfigured, configPath: file };
  127. }
  128. install(loc: Location, _opts: InstallOptions): WriteResult {
  129. return {
  130. files: [writeMcpEntry(loc)],
  131. notes: ['Restart VS Code for MCP changes to take effect.'],
  132. };
  133. }
  134. uninstall(loc: Location): WriteResult {
  135. return { files: [removeMcpEntry(loc)] };
  136. }
  137. printConfig(loc: Location): string {
  138. const target = mcpJsonPath(loc);
  139. const snippet = JSON.stringify({ servers: { codegraph: buildVscodeServerEntry(loc) } }, null, 2);
  140. return `# Add to ${target}\n\n${snippet}\n`;
  141. }
  142. describePaths(loc: Location): string[] {
  143. return [mcpJsonPath(loc)];
  144. }
  145. }
  146. function writeMcpEntry(loc: Location): WriteResult['files'][number] {
  147. const file = mcpJsonPath(loc);
  148. const existed = fs.existsSync(file);
  149. let text = readConfigText(file);
  150. if (!text.trim()) text = '{}\n';
  151. const config = parseConfig(text);
  152. const before = config.servers?.codegraph;
  153. const after = buildVscodeServerEntry(loc);
  154. if (jsonDeepEqual(before, after)) {
  155. return { path: file, action: 'unchanged' };
  156. }
  157. // Surgical edit — preserves comments, formatting, and sibling
  158. // servers ("servers" is created when missing).
  159. const edits = modify(text, ['servers', 'codegraph'], after, {
  160. formattingOptions: FORMATTING,
  161. });
  162. const updated = applyEdits(text, edits);
  163. atomicWriteFileSync(file, updated);
  164. return { path: file, action: existed ? 'updated' : 'created' };
  165. }
  166. function removeMcpEntry(loc: Location): WriteResult['files'][number] {
  167. const file = mcpJsonPath(loc);
  168. if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
  169. const text = readConfigText(file);
  170. const config = parseConfig(text);
  171. if (!config.servers?.codegraph) return { path: file, action: 'not-found' };
  172. let edits = modify(text, ['servers', 'codegraph'], undefined, {
  173. formattingOptions: FORMATTING,
  174. });
  175. let updated = applyEdits(text, edits);
  176. // Drop an emptied `servers` wrapper; the file itself is left in
  177. // place — VS Code recreates/reads it and siblings like `inputs`
  178. // may remain.
  179. const afterParsed = parseConfig(updated);
  180. if (afterParsed.servers && typeof afterParsed.servers === 'object' &&
  181. Object.keys(afterParsed.servers).length === 0) {
  182. edits = modify(updated, ['servers'], undefined, { formattingOptions: FORMATTING });
  183. updated = applyEdits(updated, edits);
  184. }
  185. atomicWriteFileSync(file, updated);
  186. return { path: file, action: 'removed' };
  187. }
  188. export const copilotVscodeTarget: AgentTarget = new CopilotVscodeTarget();