Skip to content

Commit cb2d28f

Browse files
data-douserCopilotCopilot
authored
fix: ql-mcp server must handle vscode workspace folder changes (#196)
* fix: restart MCP server on vscode workspace folder changes When workspace folders were added or removed, fireDidChange() notified VS Code that the MCP server definitions changed, causing it to stop the running server. However, because the returned McpStdioServerDefinition had the same version as before, VS Code did not restart the server. Add a monotonically increasing revision counter to McpProvider that increments on each fireDidChange() call and is appended to the version string (e.g. 2.25.1.1, 2.25.1.2). This signals VS Code that the definition has genuinely changed and triggers a server restart with the updated environment instead of only stopping the server. - Add _revision counter and getEffectiveVersion() to McpProvider - Add 4 unit tests for version-bumping behaviour - Add integration test suite for workspace folder change scenarios * SqliteStore backend + annotation, audit, and query result cache tools (#169) * fix: handle workspace folder changes in ql-mcp extension - Invalidate cached environment when workspace folders change so the next server start picks up updated folder list - getEffectiveVersion() always returns a concrete string (never undefined) so VS Code has a reliable baseline for version comparison - Add multi-root workspace integration tests verifying environment correctness (CODEQL_MCP_WORKSPACE_FOLDERS includes/excludes folders) - Add unit test for workspace folder change handler behavior - Add unit test for always-defined version string - Add multiRoot test profile with 4 workspace folders * fix: remove machine-specific temp folder from workspace fixture Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/bc6e5960-6b48-4be3-addb-49a4f14b6efa Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> * fix: apply PR review feedback - env invalidation, +rN version format, vi.spyOn cleanup Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/b3b84dce-0b52-4c7d-9391-014500df1b9a Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> * Update extensions/vscode/test/suite/workspace-folder-change.integration.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Nathan Randall <70299490+data-douser@users.noreply.github.com> * Update extensions/vscode/test/suite/workspace-folder-change.integration.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Nathan Randall <70299490+data-douser@users.noreply.github.com> * fix: remove duplicate assertion, add try/finally for spy, cache extension version Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/28160745-507f-46aa-b350-6fd6d19315ba Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> * fix: populate env cache before folder add in test; re-instantiate provider after mock change Agent-Logs-Url: https://github.com/advanced-security/codeql-development-mcp-server/sessions/a42c54f5-5445-42e7-bad8-f86e1aa717f7 Co-authored-by: data-douser <70299490+data-douser@users.noreply.github.com> --------- Signed-off-by: Nathan Randall <70299490+data-douser@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent fdc42c5 commit cb2d28f

File tree

9 files changed

+395
-10
lines changed

9 files changed

+395
-10
lines changed

extensions/vscode/esbuild.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const testSuiteConfig = {
4141
'test/suite/mcp-resource-e2e.integration.test.ts',
4242
'test/suite/mcp-server.integration.test.ts',
4343
'test/suite/mcp-tool-e2e.integration.test.ts',
44+
'test/suite/workspace-folder-change.integration.test.ts',
4445
'test/suite/workspace-scenario.integration.test.ts',
4546
],
4647
outdir: 'dist/test/suite',

extensions/vscode/src/extension.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,19 +81,20 @@ export async function activate(
8181
context.subscriptions.push(
8282
vscode.workspace.onDidChangeConfiguration((e) => {
8383
if (e.affectsConfiguration('codeql-mcp')) {
84-
logger.info('Configuration changed — rebuilding environment');
85-
envBuilder.invalidate();
86-
mcpProvider.fireDidChange();
84+
logger.info('Configuration changed — requesting MCP server restart');
85+
mcpProvider.requestRestart();
8786
}
8887
}),
8988
);
9089

91-
// Re-scan when workspace folders change
90+
// Invalidate cached environment when workspace folders change.
91+
// VS Code itself manages MCP server lifecycle when roots change
92+
// (stopping and restarting the server as needed). We just clear
93+
// the cached env so the next server start picks up updated folders.
9294
context.subscriptions.push(
9395
vscode.workspace.onDidChangeWorkspaceFolders(() => {
94-
logger.info('Workspace folders changed — refreshing watchers');
96+
logger.info('Workspace folders changed — invalidating environment cache');
9597
envBuilder.invalidate();
96-
mcpProvider.fireDidChange();
9798
}),
9899
);
99100

extensions/vscode/src/server/mcp-provider.ts

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,69 @@ export class McpProvider
2020
private readonly _onDidChange = new vscode.EventEmitter<void>();
2121
readonly onDidChangeMcpServerDefinitions = this._onDidChange.event;
2222

23+
/**
24+
* Monotonically increasing counter, bumped by `requestRestart()`.
25+
* Appended to the version string so that VS Code sees a genuinely new
26+
* server definition and triggers a stop → restart cycle.
27+
*/
28+
private _revision = 0;
29+
30+
/**
31+
* Cached extension version to avoid repeated synchronous `readFileSync`
32+
* calls on every `getEffectiveVersion()` invocation.
33+
*/
34+
private readonly _extensionVersion: string;
35+
2336
constructor(
2437
private readonly serverManager: ServerManager,
2538
private readonly envBuilder: EnvironmentBuilder,
2639
private readonly logger: Logger,
2740
) {
2841
super();
42+
this._extensionVersion = serverManager.getExtensionVersion();
2943
this.push(this._onDidChange);
3044
}
3145

32-
/** Notify VS Code that the MCP server definitions have changed. */
46+
/**
47+
* Soft notification: tell VS Code that definitions may have changed.
48+
*
49+
* Does NOT bump the version, so VS Code will re-query
50+
* `provideMcpServerDefinitions()` / `resolveMcpServerDefinition()` but
51+
* will NOT restart the server. Use for lightweight updates (file watcher
52+
* events, extension changes, background install completion) where the
53+
* running server can continue with its current environment.
54+
*/
3355
fireDidChange(): void {
3456
this._onDidChange.fire();
3557
}
3658

59+
/**
60+
* Request that VS Code restart the MCP server with a fresh environment.
61+
*
62+
* Invalidates the cached environment and bumps the internal revision counter
63+
* so that the next call to `provideMcpServerDefinitions()` returns a
64+
* definition with a different `version` string. VS Code compares the new
65+
* version to the running server's version and, seeing a change, triggers a
66+
* stop → start cycle.
67+
*
68+
* Use for changes that require a server restart (configuration changes).
69+
*/
70+
requestRestart(): void {
71+
this.envBuilder.invalidate();
72+
this._revision++;
73+
this.logger.info(
74+
`Requesting ql-mcp restart (revision ${this._revision})...`,
75+
);
76+
this._onDidChange.fire();
77+
}
78+
3779
async provideMcpServerDefinitions(
3880
_token: vscode.CancellationToken,
3981
): Promise<vscode.McpStdioServerDefinition[]> {
4082
const command = this.serverManager.getCommand();
4183
const args = this.serverManager.getArgs();
4284
const env = await this.envBuilder.build();
43-
const version = this.serverManager.getVersion();
85+
const version = this.getEffectiveVersion();
4486

4587
this.logger.info(
4688
`Providing MCP server definition: ${command} ${args.join(' ')}`,
@@ -66,4 +108,26 @@ export class McpProvider
66108
server.env = env;
67109
return server;
68110
}
111+
112+
/**
113+
* Computes the version string for the MCP server definition.
114+
*
115+
* Always returns a concrete string so that VS Code has a reliable
116+
* baseline for version comparison. When `serverManager.getVersion()`
117+
* returns `undefined` (the "latest" / unpinned case), the extension
118+
* version is used as the base instead.
119+
*
120+
* After one or more `requestRestart()` calls, a `+rN` revision suffix
121+
* is appended so that the version is always different from the
122+
* previous one. VS Code uses the version to decide whether to
123+
* restart a running server: a changed version triggers a stop → start
124+
* cycle.
125+
*/
126+
private getEffectiveVersion(): string {
127+
const base = this.serverManager.getVersion() ?? this._extensionVersion;
128+
if (this._revision === 0) {
129+
return base;
130+
}
131+
return `${base}+r${this._revision}`;
132+
}
69133
}

extensions/vscode/test/extension.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ vi.mock('../src/server/mcp-provider', () => ({
5050
resolveMcpServerDefinition: vi.fn().mockResolvedValue(undefined),
5151
onDidChangeMcpServerDefinitions: vi.fn(),
5252
fireDidChange: vi.fn(),
53+
requestRestart: vi.fn(),
5354
dispose: vi.fn(),
5455
};
5556
}),
@@ -102,6 +103,8 @@ vi.mock('../src/bridge/environment-builder', () => ({
102103

103104
import { activate, deactivate } from '../src/extension';
104105
import * as vscode from 'vscode';
106+
import { McpProvider } from '../src/server/mcp-provider';
107+
import { EnvironmentBuilder } from '../src/bridge/environment-builder';
105108

106109
function createMockContext(): vscode.ExtensionContext {
107110
return {
@@ -168,4 +171,39 @@ describe('Extension', () => {
168171
it('should deactivate even if activate was never called', () => {
169172
expect(() => deactivate()).not.toThrow();
170173
});
174+
175+
it('should invalidate env cache on workspace folder changes', async () => {
176+
// Capture the workspace folder change callback
177+
let workspaceFolderChangeCallback: Function | undefined;
178+
const spy = vi.spyOn(vscode.workspace, 'onDidChangeWorkspaceFolders').mockImplementation(
179+
(cb: Function) => { workspaceFolderChangeCallback = cb; return { dispose: vi.fn() }; },
180+
);
181+
182+
await activate(ctx);
183+
184+
// The extension should have registered a workspace folder change handler
185+
expect(workspaceFolderChangeCallback).toBeDefined();
186+
187+
// Get the mock instances created during activation
188+
const mcpProviderInstance = vi.mocked(McpProvider).mock.results[0]?.value;
189+
const envBuilderInstance = vi.mocked(EnvironmentBuilder).mock.results[0]?.value;
190+
191+
// Reset call counts from activation
192+
mcpProviderInstance.fireDidChange.mockClear();
193+
mcpProviderInstance.requestRestart.mockClear();
194+
envBuilderInstance.invalidate.mockClear();
195+
196+
try {
197+
// Simulate workspace folder change
198+
workspaceFolderChangeCallback!();
199+
200+
// Cache invalidated immediately; no VS Code notification.
201+
// VS Code manages MCP server lifecycle (stop/restart) when roots change.
202+
expect(envBuilderInstance.invalidate).toHaveBeenCalledTimes(1);
203+
expect(mcpProviderInstance.fireDidChange).not.toHaveBeenCalled();
204+
expect(mcpProviderInstance.requestRestart).not.toHaveBeenCalled();
205+
} finally {
206+
spy.mockRestore();
207+
}
208+
});
171209
});

extensions/vscode/test/fixtures/multi-root-workspace/folder-c/.gitkeep

Whitespace-only changes.

extensions/vscode/test/fixtures/multi-root-workspace/folder-d/.gitkeep

Whitespace-only changes.
Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
{
22
"folders": [
3-
{ "path": "folder-a" },
4-
{ "path": "folder-b" }
3+
{
4+
"path": "folder-a"
5+
},
6+
{
7+
"path": "folder-b"
8+
},
9+
{
10+
"path": "folder-c"
11+
},
12+
{
13+
"path": "folder-d"
14+
}
515
]
616
}

extensions/vscode/test/server/mcp-provider.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ function createMockServerManager() {
77
return {
88
getCommand: vi.fn().mockReturnValue('npx'),
99
getArgs: vi.fn().mockReturnValue(['-y', 'codeql-development-mcp-server']),
10+
getExtensionVersion: vi.fn().mockReturnValue('2.25.1'),
1011
getVersion: vi.fn().mockReturnValue(undefined),
1112
getDescription: vi.fn().mockReturnValue('npx -y codeql-development-mcp-server'),
1213
getInstallDir: vi.fn().mockReturnValue('/mock/install'),
@@ -111,6 +112,26 @@ describe('McpProvider', () => {
111112
expect(definitions).toHaveLength(1);
112113
});
113114

115+
it('should always provide a defined version string even when serverVersion is latest', async () => {
116+
// When serverManager.getVersion() returns undefined ("latest" mode),
117+
// the definition must still carry a concrete version string so that
118+
// VS Code has a baseline for version comparison. An undefined initial
119+
// version prevents VS Code from detecting changes after requestRestart().
120+
//
121+
// NOTE: McpProvider caches getExtensionVersion() in its constructor, so the
122+
// mock must be configured before constructing the provider.
123+
serverManager.getVersion.mockReturnValue(undefined);
124+
serverManager.getExtensionVersion.mockReturnValue('2.25.1');
125+
provider = new McpProvider(serverManager, envBuilder, logger);
126+
127+
const token = { isCancellationRequested: false, onCancellationRequested: vi.fn() };
128+
const definitions = await provider.provideMcpServerDefinitions(token as any);
129+
130+
expect(definitions![0].version).toBeDefined();
131+
expect(typeof definitions![0].version).toBe('string');
132+
expect(definitions![0].version!.length).toBeGreaterThan(0);
133+
});
134+
114135
it('should pass version from serverManager when pinned', async () => {
115136
serverManager.getVersion.mockReturnValue('2.20.0');
116137

@@ -120,6 +141,78 @@ describe('McpProvider', () => {
120141
expect(definitions![0].version).toBe('2.20.0');
121142
});
122143

144+
it('should not change version on fireDidChange (soft signal)', async () => {
145+
const token = { isCancellationRequested: false, onCancellationRequested: vi.fn() };
146+
const before = await provider.provideMcpServerDefinitions(token as any);
147+
const versionBefore = before![0].version;
148+
149+
provider.fireDidChange();
150+
const after = await provider.provideMcpServerDefinitions(token as any);
151+
152+
expect(after![0].version).toBe(versionBefore);
153+
});
154+
155+
it('should produce a different version after requestRestart', async () => {
156+
const token = { isCancellationRequested: false, onCancellationRequested: vi.fn() };
157+
const before = await provider.provideMcpServerDefinitions(token as any);
158+
const versionBefore = before![0].version;
159+
160+
provider.requestRestart();
161+
const after = await provider.provideMcpServerDefinitions(token as any);
162+
const versionAfter = after![0].version;
163+
164+
expect(versionAfter).toBeDefined();
165+
expect(versionAfter).not.toBe(versionBefore);
166+
});
167+
168+
it('should produce distinct versions after successive requestRestart calls', async () => {
169+
const token = { isCancellationRequested: false, onCancellationRequested: vi.fn() };
170+
171+
provider.requestRestart();
172+
const defs1 = await provider.provideMcpServerDefinitions(token as any);
173+
174+
provider.requestRestart();
175+
const defs2 = await provider.provideMcpServerDefinitions(token as any);
176+
177+
expect(defs1![0].version).not.toBe(defs2![0].version);
178+
});
179+
180+
it('should append revision to pinned version after requestRestart', async () => {
181+
serverManager.getVersion.mockReturnValue('2.20.0');
182+
const token = { isCancellationRequested: false, onCancellationRequested: vi.fn() };
183+
184+
provider.requestRestart();
185+
const definitions = await provider.provideMcpServerDefinitions(token as any);
186+
187+
expect(definitions![0].version).toMatch(/^2\.20\.0\+r\d+$/);
188+
});
189+
190+
it('should append revision to extension version after requestRestart when version is latest', async () => {
191+
serverManager.getVersion.mockReturnValue(undefined);
192+
serverManager.getExtensionVersion.mockReturnValue('2.25.1');
193+
const token = { isCancellationRequested: false, onCancellationRequested: vi.fn() };
194+
195+
provider.requestRestart();
196+
const definitions = await provider.provideMcpServerDefinitions(token as any);
197+
198+
expect(definitions![0].version).toMatch(/^2\.25\.1\+r\d+$/);
199+
});
200+
201+
it('should fire change event when requestRestart is called', () => {
202+
const listener = vi.fn();
203+
provider.onDidChangeMcpServerDefinitions(listener);
204+
205+
provider.requestRestart();
206+
207+
expect(listener).toHaveBeenCalledTimes(1);
208+
});
209+
210+
it('should invalidate env cache when requestRestart is called', () => {
211+
provider.requestRestart();
212+
213+
expect(envBuilder.invalidate).toHaveBeenCalledTimes(1);
214+
});
215+
123216
it('should resolve definition by refreshing environment', async () => {
124217
const updatedEnv = { CODEQL_PATH: '/new/path', TRANSPORT_MODE: 'stdio' };
125218
envBuilder.build.mockResolvedValue(updatedEnv);

0 commit comments

Comments
 (0)