Adds three new installer targets so `codegraph install` can wire the
MCP server into GitHub Copilot surfaces:
- copilot-vscode: .vscode/mcp.json (local) or the VS Code User-dir
mcp.json (global), JSONC-surgical edits, `--path` pinned via
${workspaceFolder} for global installs
- copilot-cli: ~/.copilot/mcp-config.json
- copilot-jetbrains: github-copilot config dir (XDG / %LOCALAPPDATA%)
Detection, install, uninstall, and --print-config are covered for all
three in installer-targets.test.ts, including platform-specific path
resolution.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
203 lines
7.2 KiB
TypeScript
203 lines
7.2 KiB
TypeScript
/**
|
|
* VS Code (GitHub Copilot Chat) target.
|
|
*
|
|
* - MCP server entry to `.vscode/mcp.json` (local, workspace-scoped)
|
|
* or the user-level `mcp.json` in the VS Code User dir (global):
|
|
*
|
|
* macOS: ~/Library/Application Support/Code/User/mcp.json
|
|
* Windows: %APPDATA%\Code\User\mcp.json
|
|
* Linux: $XDG_CONFIG_HOME|~/.config/Code/User/mcp.json
|
|
*
|
|
* VS Code moved MCP config out of settings.json into this dedicated
|
|
* `mcp.json` (v1.102, "MCP: Open User Configuration"). Shape is
|
|
* `{ "servers": { "<name>": { "type": "stdio", "command", "args" } } }`
|
|
* — note `servers`, not the `mcpServers` wrapper Claude/Cursor use.
|
|
* - No instructions file: Copilot Chat consumes the MCP `initialize`
|
|
* instructions, the single source of truth (#529).
|
|
* - No permissions concept — `autoAllow` is silently ignored.
|
|
*
|
|
* ## Why we inject `--path` (mirrors Cursor)
|
|
*
|
|
* VS Code's docs don't specify the working directory stdio MCP servers
|
|
* are launched with, and (like Cursor) we can't rely on it being the
|
|
* workspace root. Rather than depend on undocumented cwd behavior we
|
|
* pin the project explicitly:
|
|
*
|
|
* - `local` install: absolute path (known at install time).
|
|
* - `global` install: `${workspaceFolder}` — VS Code expands its
|
|
* standard variables inside mcp.json, giving per-workspace behavior
|
|
* from a single user-level config.
|
|
*
|
|
* ## JSONC
|
|
*
|
|
* VS Code parses its config files as JSONC (comments + trailing commas
|
|
* allowed), so reads + writes go through `jsonc-parser` — surgical
|
|
* edits that preserve sibling servers, user comments, and formatting
|
|
* across install / re-install / uninstall (same approach as opencode).
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser';
|
|
import {
|
|
AgentTarget,
|
|
DetectionResult,
|
|
InstallOptions,
|
|
Location,
|
|
WriteResult,
|
|
} from './types';
|
|
import {
|
|
atomicWriteFileSync,
|
|
getMcpServerConfig,
|
|
jsonDeepEqual,
|
|
} from './shared';
|
|
|
|
function vscodeUserDir(): string {
|
|
const home = os.homedir();
|
|
if (process.platform === 'win32') {
|
|
const appData = process.env.APPDATA && process.env.APPDATA.trim().length > 0
|
|
? process.env.APPDATA
|
|
: path.join(home, 'AppData', 'Roaming');
|
|
return path.join(appData, 'Code', 'User');
|
|
}
|
|
if (process.platform === 'darwin') {
|
|
return path.join(home, 'Library', 'Application Support', 'Code', 'User');
|
|
}
|
|
const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0
|
|
? process.env.XDG_CONFIG_HOME
|
|
: path.join(home, '.config');
|
|
return path.join(xdg, 'Code', 'User');
|
|
}
|
|
|
|
function mcpJsonPath(loc: Location): string {
|
|
return loc === 'global'
|
|
? path.join(vscodeUserDir(), 'mcp.json')
|
|
: path.join(process.cwd(), '.vscode', 'mcp.json');
|
|
}
|
|
|
|
/**
|
|
* Build the codegraph server entry for VS Code at the given location.
|
|
* Shared `{type, command, args}` shape plus the `--path` pin — see
|
|
* file header for why we don't trust VS Code's launch cwd.
|
|
*/
|
|
function buildVscodeServerEntry(loc: Location): { type: string; command: string; args: string[] } {
|
|
const base = getMcpServerConfig();
|
|
const pathArg = loc === 'local' ? process.cwd() : '${workspaceFolder}';
|
|
return { ...base, args: [...base.args, '--path', pathArg] };
|
|
}
|
|
|
|
function readConfigText(file: string): string {
|
|
if (!fs.existsSync(file)) return '';
|
|
return fs.readFileSync(file, 'utf-8');
|
|
}
|
|
|
|
function parseConfig(text: string): Record<string, any> {
|
|
if (!text.trim()) return {};
|
|
const errors: any[] = [];
|
|
const result = parseJsonc(text, errors, { allowTrailingComma: true });
|
|
if (result == null || typeof result !== 'object' || Array.isArray(result)) {
|
|
return {};
|
|
}
|
|
return result as Record<string, any>;
|
|
}
|
|
|
|
const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' };
|
|
|
|
class CopilotVscodeTarget implements AgentTarget {
|
|
readonly id = 'copilot-vscode' as const;
|
|
readonly displayName = 'VS Code (Copilot Chat)';
|
|
readonly docsUrl = 'https://code.visualstudio.com/docs/copilot/customization/mcp-servers';
|
|
|
|
supportsLocation(_loc: Location): boolean {
|
|
return true;
|
|
}
|
|
|
|
detect(loc: Location): DetectionResult {
|
|
const file = mcpJsonPath(loc);
|
|
const config = parseConfig(readConfigText(file));
|
|
const alreadyConfigured = !!config.servers?.codegraph;
|
|
// "Installed" heuristic: the VS Code User dir (created on first
|
|
// launch) or ~/.vscode (extensions dir) for global; an existing
|
|
// .vscode/ dir in the project for local.
|
|
const installed = loc === 'global'
|
|
? fs.existsSync(vscodeUserDir()) || fs.existsSync(path.join(os.homedir(), '.vscode'))
|
|
: fs.existsSync(path.join(process.cwd(), '.vscode'));
|
|
return { installed, alreadyConfigured, configPath: file };
|
|
}
|
|
|
|
install(loc: Location, _opts: InstallOptions): WriteResult {
|
|
return {
|
|
files: [writeMcpEntry(loc)],
|
|
notes: ['Restart VS Code for MCP changes to take effect.'],
|
|
};
|
|
}
|
|
|
|
uninstall(loc: Location): WriteResult {
|
|
return { files: [removeMcpEntry(loc)] };
|
|
}
|
|
|
|
printConfig(loc: Location): string {
|
|
const target = mcpJsonPath(loc);
|
|
const snippet = JSON.stringify({ servers: { codegraph: buildVscodeServerEntry(loc) } }, null, 2);
|
|
return `# Add to ${target}\n\n${snippet}\n`;
|
|
}
|
|
|
|
describePaths(loc: Location): string[] {
|
|
return [mcpJsonPath(loc)];
|
|
}
|
|
}
|
|
|
|
function writeMcpEntry(loc: Location): WriteResult['files'][number] {
|
|
const file = mcpJsonPath(loc);
|
|
const existed = fs.existsSync(file);
|
|
let text = readConfigText(file);
|
|
if (!text.trim()) text = '{}\n';
|
|
|
|
const config = parseConfig(text);
|
|
const before = config.servers?.codegraph;
|
|
const after = buildVscodeServerEntry(loc);
|
|
|
|
if (jsonDeepEqual(before, after)) {
|
|
return { path: file, action: 'unchanged' };
|
|
}
|
|
|
|
// Surgical edit — preserves comments, formatting, and sibling
|
|
// servers ("servers" is created when missing).
|
|
const edits = modify(text, ['servers', 'codegraph'], after, {
|
|
formattingOptions: FORMATTING,
|
|
});
|
|
const updated = applyEdits(text, edits);
|
|
atomicWriteFileSync(file, updated);
|
|
|
|
return { path: file, action: existed ? 'updated' : 'created' };
|
|
}
|
|
|
|
function removeMcpEntry(loc: Location): WriteResult['files'][number] {
|
|
const file = mcpJsonPath(loc);
|
|
if (!fs.existsSync(file)) return { path: file, action: 'not-found' };
|
|
const text = readConfigText(file);
|
|
const config = parseConfig(text);
|
|
if (!config.servers?.codegraph) return { path: file, action: 'not-found' };
|
|
|
|
let edits = modify(text, ['servers', 'codegraph'], undefined, {
|
|
formattingOptions: FORMATTING,
|
|
});
|
|
let updated = applyEdits(text, edits);
|
|
|
|
// Drop an emptied `servers` wrapper; the file itself is left in
|
|
// place — VS Code recreates/reads it and siblings like `inputs`
|
|
// may remain.
|
|
const afterParsed = parseConfig(updated);
|
|
if (afterParsed.servers && typeof afterParsed.servers === 'object' &&
|
|
Object.keys(afterParsed.servers).length === 0) {
|
|
edits = modify(updated, ['servers'], undefined, { formattingOptions: FORMATTING });
|
|
updated = applyEdits(updated, edits);
|
|
}
|
|
|
|
atomicWriteFileSync(file, updated);
|
|
return { path: file, action: 'removed' };
|
|
}
|
|
|
|
export const copilotVscodeTarget: AgentTarget = new CopilotVscodeTarget();
|