From c71d7cff48185c38d45dc89fbd6d5a01b8e34bfe Mon Sep 17 00:00:00 2001 From: Tim Rogers Date: Thu, 5 Mar 2026 12:22:39 +0000 Subject: [PATCH 1/2] feat: add GitHub Copilot CLI as an MCP client Add CopilotCLIMCPClient to configure the PostHog MCP server for the GitHub Copilot CLI. This is a JSON-file-based client like Cursor and VS Code, but with CLI-based detection like Codex. Key design decisions: - Detection: runs `copilot --version` (similar to Codex) rather than platform checks, since the CLI can be installed on any OS - Config path: ~/.copilot/mcp-config.json (macOS/Linux) or %USERPROFILE%\.copilot\mcp-config.json (Windows) - Config key: `mcpServers` (the default, same as Cursor/Claude Desktop) - Transport: streamable-http with native HTTP config (like Cursor) - Server config includes `type: "http"` field, which Copilot CLI requires for all entries (similar to VS Code's `type: "http"`) - OAuth mode omits the Authorization header, API key mode includes it Differs from VS Code in using `mcpServers` (not `servers`) and using USERPROFILE (not APPDATA) for the Windows config path. Differs from Cursor in adding the explicit `type: "http"` field. Differs from CLI-based clients (Claude Code, Codex) in writing a JSON config file rather than shelling out to a CLI subcommand. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../clients/__tests__/copilot-cli.test.ts | 449 ++++++++++++++++++ .../clients/copilot-cli.ts | 94 ++++ src/steps/add-mcp-server-to-clients/index.ts | 2 + 3 files changed, 545 insertions(+) create mode 100644 src/steps/add-mcp-server-to-clients/clients/__tests__/copilot-cli.test.ts create mode 100644 src/steps/add-mcp-server-to-clients/clients/copilot-cli.ts diff --git a/src/steps/add-mcp-server-to-clients/clients/__tests__/copilot-cli.test.ts b/src/steps/add-mcp-server-to-clients/clients/__tests__/copilot-cli.test.ts new file mode 100644 index 00000000..821acaa8 --- /dev/null +++ b/src/steps/add-mcp-server-to-clients/clients/__tests__/copilot-cli.test.ts @@ -0,0 +1,449 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CopilotCLIMCPClient } from '../copilot-cli'; +import { buildMCPUrl } from '../../defaults'; + +jest.mock('node:child_process', () => ({ + execSync: jest.fn(), +})); + +jest.mock('fs', () => ({ + promises: { + mkdir: jest.fn(), + readFile: jest.fn(), + writeFile: jest.fn(), + }, + existsSync: jest.fn(), +})); + +jest.mock('os', () => ({ + homedir: jest.fn(), +})); + +jest.mock('../../defaults', () => ({ + DefaultMCPClientConfig: { + parse: jest.fn(), + }, + buildMCPUrl: jest.fn(), +})); + +describe('CopilotCLIMCPClient', () => { + let client: CopilotCLIMCPClient; + const mockHomeDir = '/mock/home'; + const mockApiKey = 'test-api-key'; + const mockServerConfig = { + type: 'http', + url: 'https://mcp.posthog.com/mcp', + headers: { Authorization: `Bearer ${mockApiKey}` }, + }; + + const { execSync } = require('node:child_process'); + const execSyncMock = execSync as jest.Mock; + + const mkdirMock = fs.promises.mkdir as jest.Mock; + const readFileMock = fs.promises.readFile as jest.Mock; + const writeFileMock = fs.promises.writeFile as jest.Mock; + const existsSyncMock = fs.existsSync as jest.Mock; + const homedirMock = os.homedir as jest.Mock; + const buildMCPUrlMock = buildMCPUrl as jest.Mock; + + const originalPlatform = process.platform; + + beforeEach(() => { + client = new CopilotCLIMCPClient(); + jest.clearAllMocks(); + homedirMock.mockReturnValue(mockHomeDir); + buildMCPUrlMock.mockReturnValue('https://mcp.posthog.com/mcp'); + + const { DefaultMCPClientConfig } = require('../../defaults'); + DefaultMCPClientConfig.parse.mockImplementation((data: any) => data); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + }); + }); + + describe('constructor', () => { + it('should set the correct name', () => { + expect(client.name).toBe('Copilot CLI'); + }); + }); + + describe('isClientSupported', () => { + it('should return true when copilot binary is available', async () => { + execSyncMock.mockReturnValue(undefined); + + await expect(client.isClientSupported()).resolves.toBe(true); + expect(execSyncMock).toHaveBeenCalledWith('copilot --version', { + stdio: 'ignore', + }); + }); + + it('should return false when copilot binary is missing', async () => { + execSyncMock.mockImplementation(() => { + throw new Error('not found'); + }); + + await expect(client.isClientSupported()).resolves.toBe(false); + }); + }); + + describe('getConfigPath', () => { + it('should return correct path for macOS', async () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true, + }); + + const configPath = await client.getConfigPath(); + expect(configPath).toBe( + path.join(mockHomeDir, '.copilot', 'mcp-config.json'), + ); + }); + + it('should return correct path for Linux', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true, + }); + + const configPath = await client.getConfigPath(); + expect(configPath).toBe( + path.join(mockHomeDir, '.copilot', 'mcp-config.json'), + ); + }); + + it('should return correct path for Windows', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true, + }); + + const mockUserProfile = 'C:\\Users\\Test'; + process.env.USERPROFILE = mockUserProfile; + + const configPath = await client.getConfigPath(); + expect(configPath).toBe( + path.join(mockUserProfile, '.copilot', 'mcp-config.json'), + ); + }); + + it('should fall back to homedir on Windows when USERPROFILE is unset', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true, + }); + delete process.env.USERPROFILE; + + const configPath = await client.getConfigPath(); + expect(configPath).toBe( + path.join(mockHomeDir, '.copilot', 'mcp-config.json'), + ); + }); + }); + + describe('isServerInstalled', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true, + }); + }); + + it('should return false when config file does not exist', async () => { + existsSyncMock.mockReturnValue(false); + + const result = await client.isServerInstalled(); + expect(result).toBe(false); + }); + + it('should return false when config file exists but posthog server is not configured', async () => { + existsSyncMock.mockReturnValue(true); + const configData = { + mcpServers: { + otherServer: mockServerConfig, + }, + }; + readFileMock.mockResolvedValue(JSON.stringify(configData)); + + const result = await client.isServerInstalled(); + expect(result).toBe(false); + }); + + it('should return true when posthog server is configured', async () => { + existsSyncMock.mockReturnValue(true); + const configData = { + mcpServers: { + posthog: mockServerConfig, + otherServer: mockServerConfig, + }, + }; + readFileMock.mockResolvedValue(JSON.stringify(configData)); + + const result = await client.isServerInstalled(); + expect(result).toBe(true); + }); + + it('should return false when config file is invalid JSON', async () => { + existsSyncMock.mockReturnValue(true); + readFileMock.mockResolvedValue('invalid json'); + + const result = await client.isServerInstalled(); + expect(result).toBe(false); + }); + + it('should return false when readFile throws an error', async () => { + existsSyncMock.mockReturnValue(true); + readFileMock.mockRejectedValue(new Error('File read error')); + + const result = await client.isServerInstalled(); + expect(result).toBe(false); + }); + }); + + describe('addServer', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true, + }); + }); + + it('should create config directory and add server when config file does not exist', async () => { + existsSyncMock.mockReturnValue(false); + + await client.addServer(mockApiKey); + + const expectedConfigPath = path.join( + mockHomeDir, + '.copilot', + 'mcp-config.json', + ); + const expectedConfigDir = path.dirname(expectedConfigPath); + + expect(mkdirMock).toHaveBeenCalledWith(expectedConfigDir, { + recursive: true, + }); + + expect(writeFileMock).toHaveBeenCalledWith( + expectedConfigPath, + JSON.stringify( + { + mcpServers: { + posthog: mockServerConfig, + }, + }, + null, + 2, + ), + 'utf8', + ); + }); + + it('should merge with existing config when config file exists', async () => { + existsSyncMock.mockReturnValue(true); + const existingConfig = { + mcpServers: { + existingServer: { + command: 'existing', + args: [], + env: {}, + }, + }, + }; + readFileMock.mockResolvedValue(JSON.stringify(existingConfig)); + + await client.addServer(mockApiKey); + + expect(writeFileMock).toHaveBeenCalledWith( + expect.any(String), + JSON.stringify( + { + mcpServers: { + existingServer: existingConfig.mcpServers.existingServer, + posthog: mockServerConfig, + }, + }, + null, + 2, + ), + 'utf8', + ); + }); + + it('should not overwrite existing config when it is invalid', async () => { + existsSyncMock.mockReturnValue(true); + readFileMock.mockResolvedValue( + JSON.stringify({ + invalidKey: { + existingServer: { + command: 'existing', + args: [], + env: {}, + }, + }, + x: 'y', + }), + ); + + await client.addServer(mockApiKey); + + expect(writeFileMock).toHaveBeenCalledWith( + expect.any(String), + JSON.stringify( + { + invalidKey: { + existingServer: { + command: 'existing', + args: [], + env: {}, + }, + }, + x: 'y', + mcpServers: { + posthog: mockServerConfig, + }, + }, + null, + 2, + ), + 'utf8', + ); + }); + + it('should call buildMCPUrl with the correct transport type', async () => { + existsSyncMock.mockReturnValue(false); + + await client.addServer(mockApiKey); + + expect(buildMCPUrlMock).toHaveBeenCalledWith( + 'streamable-http', + undefined, + undefined, + ); + }); + + it('should call buildMCPUrl with undefined API key for OAuth mode', async () => { + existsSyncMock.mockReturnValue(false); + + await client.addServer(undefined); + + expect(buildMCPUrlMock).toHaveBeenCalledWith( + 'streamable-http', + undefined, + undefined, + ); + + const expectedConfigPath = path.join( + mockHomeDir, + '.copilot', + 'mcp-config.json', + ); + expect(writeFileMock).toHaveBeenCalledWith( + expectedConfigPath, + JSON.stringify( + { + mcpServers: { + posthog: { + type: 'http', + url: 'https://mcp.posthog.com/mcp', + }, + }, + }, + null, + 2, + ), + 'utf8', + ); + }); + }); + + describe('removeServer', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true, + }); + }); + + it('should do nothing when config file does not exist', async () => { + existsSyncMock.mockReturnValue(false); + + await client.removeServer(); + + expect(readFileMock).not.toHaveBeenCalled(); + expect(writeFileMock).not.toHaveBeenCalled(); + }); + + it('should remove posthog server from config', async () => { + existsSyncMock.mockReturnValue(true); + const configWithPosthog = { + mcpServers: { + posthog: mockServerConfig, + otherServer: { + command: 'other', + args: [], + env: {}, + }, + }, + }; + readFileMock.mockResolvedValue(JSON.stringify(configWithPosthog)); + + await client.removeServer(); + + expect(writeFileMock).toHaveBeenCalledWith( + expect.any(String), + JSON.stringify( + { + mcpServers: { + otherServer: configWithPosthog.mcpServers.otherServer, + }, + }, + null, + 2, + ), + 'utf8', + ); + }); + + it('should do nothing when posthog server is not in config', async () => { + existsSyncMock.mockReturnValue(true); + const configWithoutPosthog = { + mcpServers: { + otherServer: { + command: 'other', + args: [], + env: {}, + }, + }, + }; + readFileMock.mockResolvedValue(JSON.stringify(configWithoutPosthog)); + + await client.removeServer(); + + expect(writeFileMock).not.toHaveBeenCalled(); + }); + + it('should handle invalid JSON gracefully', async () => { + existsSyncMock.mockReturnValue(true); + readFileMock.mockResolvedValue('invalid json'); + + await client.removeServer(); + + expect(writeFileMock).not.toHaveBeenCalled(); + }); + + it('should handle file read errors gracefully', async () => { + existsSyncMock.mockReturnValue(true); + readFileMock.mockRejectedValue(new Error('File read error')); + + await client.removeServer(); + + expect(writeFileMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/steps/add-mcp-server-to-clients/clients/copilot-cli.ts b/src/steps/add-mcp-server-to-clients/clients/copilot-cli.ts new file mode 100644 index 00000000..dc5c902f --- /dev/null +++ b/src/steps/add-mcp-server-to-clients/clients/copilot-cli.ts @@ -0,0 +1,94 @@ +import { execSync } from 'node:child_process'; +import z from 'zod'; +import * as path from 'path'; +import * as os from 'os'; +import { DefaultMCPClient, MCPServerConfig } from '../MCPClient'; +import { buildMCPUrl } from '../defaults'; + +export const CopilotCLIMCPConfig = z + .object({ + mcpServers: z.record( + z.string(), + z.union([ + z.object({ + command: z.string().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + }), + z.object({ + type: z.enum(['http', 'sse']), + url: z.string(), + headers: z.record(z.string(), z.string()).optional(), + }), + ]), + ), + }) + .passthrough(); + +export type CopilotCLIMCPConfig = z.infer; + +export class CopilotCLIMCPClient extends DefaultMCPClient { + name = 'Copilot CLI'; + + async isClientSupported(): Promise { + try { + execSync('copilot --version', { stdio: 'ignore' }); + return Promise.resolve(true); + } catch { + return Promise.resolve(false); + } + } + + async getConfigPath(): Promise { + const homeDir = os.homedir(); + + if (process.platform === 'win32') { + return Promise.resolve( + path.join( + process.env.USERPROFILE || homeDir, + '.copilot', + 'mcp-config.json', + ), + ); + } + + return Promise.resolve(path.join(homeDir, '.copilot', 'mcp-config.json')); + } + + getServerConfig( + apiKey: string | undefined, + type: 'sse' | 'streamable-http', + selectedFeatures?: string[], + local?: boolean, + ): MCPServerConfig { + const url = buildMCPUrl(type, selectedFeatures, local); + + if (apiKey) { + return { + type: 'http', + url, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }; + } + + return { + type: 'http', + url, + }; + } + + async addServer( + apiKey?: string, + selectedFeatures?: string[], + local?: boolean, + ): Promise<{ success: boolean }> { + return this._addServerType( + apiKey, + 'streamable-http', + selectedFeatures, + local, + ); + } +} diff --git a/src/steps/add-mcp-server-to-clients/index.ts b/src/steps/add-mcp-server-to-clients/index.ts index 5fc562ba..e2453e25 100644 --- a/src/steps/add-mcp-server-to-clients/index.ts +++ b/src/steps/add-mcp-server-to-clients/index.ts @@ -11,6 +11,7 @@ import { ClaudeCodeMCPClient } from './clients/claude-code'; import { VisualStudioCodeClient } from './clients/visual-studio-code'; import { ZedClient } from './clients/zed'; import { CodexMCPClient } from './clients/codex'; +import { CopilotCLIMCPClient } from './clients/copilot-cli'; import { AVAILABLE_FEATURES, ALL_FEATURE_VALUES } from './defaults'; import { debug } from '../../utils/debug'; @@ -22,6 +23,7 @@ export const getSupportedClients = async (): Promise => { new VisualStudioCodeClient(), new ZedClient(), new CodexMCPClient(), + new CopilotCLIMCPClient(), ]; const supportedClients: MCPClient[] = []; From ae6bc150a60d5500d556257bacc5ea35a0a82720 Mon Sep 17 00:00:00 2001 From: Tim Rogers Date: Thu, 5 Mar 2026 21:39:55 +0000 Subject: [PATCH 2/2] Address feedback --- .../clients/__tests__/copilot-cli.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/steps/add-mcp-server-to-clients/clients/__tests__/copilot-cli.test.ts b/src/steps/add-mcp-server-to-clients/clients/__tests__/copilot-cli.test.ts index 821acaa8..aa3f4c76 100644 --- a/src/steps/add-mcp-server-to-clients/clients/__tests__/copilot-cli.test.ts +++ b/src/steps/add-mcp-server-to-clients/clients/__tests__/copilot-cli.test.ts @@ -49,6 +49,7 @@ describe('CopilotCLIMCPClient', () => { const buildMCPUrlMock = buildMCPUrl as jest.Mock; const originalPlatform = process.platform; + const originalUserProfile = process.env.USERPROFILE; beforeEach(() => { client = new CopilotCLIMCPClient(); @@ -65,6 +66,12 @@ describe('CopilotCLIMCPClient', () => { value: originalPlatform, writable: true, }); + + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } }); describe('constructor', () => {