Add MCP Root change notifications (#6502)

This commit is contained in:
Jacob MacDonald
2025-08-18 14:09:02 -07:00
committed by GitHub
parent 465ac9f547
commit 3960ccf781
4 changed files with 200 additions and 7 deletions

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
@@ -294,6 +294,88 @@ describe('WorkspaceContext with real filesystem', () => {
});
});
describe('onDirectoriesChanged', () => {
it('should call listener when adding a directory', () => {
const workspaceContext = new WorkspaceContext(cwd);
const listener = vi.fn();
workspaceContext.onDirectoriesChanged(listener);
workspaceContext.addDirectory(otherDir);
expect(listener).toHaveBeenCalledOnce();
});
it('should not call listener when adding a duplicate directory', () => {
const workspaceContext = new WorkspaceContext(cwd);
workspaceContext.addDirectory(otherDir);
const listener = vi.fn();
workspaceContext.onDirectoriesChanged(listener);
workspaceContext.addDirectory(otherDir);
expect(listener).not.toHaveBeenCalled();
});
it('should call listener when setting different directories', () => {
const workspaceContext = new WorkspaceContext(cwd);
const listener = vi.fn();
workspaceContext.onDirectoriesChanged(listener);
workspaceContext.setDirectories([otherDir]);
expect(listener).toHaveBeenCalledOnce();
});
it('should not call listener when setting same directories', () => {
const workspaceContext = new WorkspaceContext(cwd);
const listener = vi.fn();
workspaceContext.onDirectoriesChanged(listener);
workspaceContext.setDirectories([cwd]);
expect(listener).not.toHaveBeenCalled();
});
it('should support multiple listeners', () => {
const workspaceContext = new WorkspaceContext(cwd);
const listener1 = vi.fn();
const listener2 = vi.fn();
workspaceContext.onDirectoriesChanged(listener1);
workspaceContext.onDirectoriesChanged(listener2);
workspaceContext.addDirectory(otherDir);
expect(listener1).toHaveBeenCalledOnce();
expect(listener2).toHaveBeenCalledOnce();
});
it('should allow unsubscribing a listener', () => {
const workspaceContext = new WorkspaceContext(cwd);
const listener = vi.fn();
const unsubscribe = workspaceContext.onDirectoriesChanged(listener);
unsubscribe();
workspaceContext.addDirectory(otherDir);
expect(listener).not.toHaveBeenCalled();
});
it('should not fail if a listener throws an error', () => {
const workspaceContext = new WorkspaceContext(cwd);
const errorListener = () => {
throw new Error('test error');
};
const listener = vi.fn();
workspaceContext.onDirectoriesChanged(errorListener);
workspaceContext.onDirectoriesChanged(listener);
expect(() => {
workspaceContext.addDirectory(otherDir);
}).not.toThrow();
expect(listener).toHaveBeenCalledOnce();
});
});
describe('getDirectories', () => {
it('should return a copy of directories array', () => {
const workspaceContext = new WorkspaceContext(cwd);

View File

@@ -8,6 +8,8 @@ import { isNodeError } from '../utils/errors.js';
import * as fs from 'fs';
import * as path from 'path';
export type Unsubscribe = () => void;
/**
* WorkspaceContext manages multiple workspace directories and validates paths
* against them. This allows the CLI to operate on files from multiple directories
@@ -16,6 +18,7 @@ import * as path from 'path';
export class WorkspaceContext {
private directories = new Set<string>();
private initialDirectories: Set<string>;
private onDirectoriesChangedListeners = new Set<() => void>();
/**
* Creates a new WorkspaceContext with the given initial directory and optional additional directories.
@@ -31,13 +34,42 @@ export class WorkspaceContext {
this.initialDirectories = new Set(this.directories);
}
/**
* Registers a listener that is called when the workspace directories change.
* @param listener The listener to call.
* @returns A function to unsubscribe the listener.
*/
onDirectoriesChanged(listener: () => void): Unsubscribe {
this.onDirectoriesChangedListeners.add(listener);
return () => {
this.onDirectoriesChangedListeners.delete(listener);
};
}
private notifyDirectoriesChanged() {
// Iterate over a copy of the set in case a listener unsubscribes itself or others.
for (const listener of [...this.onDirectoriesChangedListeners]) {
try {
listener();
} catch (e) {
// Don't let one listener break others.
console.error('Error in WorkspaceContext listener:', e);
}
}
}
/**
* Adds a directory to the workspace.
* @param directory The directory path to add (can be relative or absolute)
* @param basePath Optional base path for resolving relative paths (defaults to cwd)
*/
addDirectory(directory: string, basePath: string = process.cwd()): void {
this.directories.add(this.resolveAndValidateDir(directory, basePath));
const resolved = this.resolveAndValidateDir(directory, basePath);
if (this.directories.has(resolved)) {
return;
}
this.directories.add(resolved);
this.notifyDirectoriesChanged();
}
private resolveAndValidateDir(
@@ -72,9 +104,17 @@ export class WorkspaceContext {
}
setDirectories(directories: readonly string[]): void {
this.directories.clear();
const newDirectories = new Set<string>();
for (const dir of directories) {
this.addDirectory(dir);
newDirectories.add(this.resolveAndValidateDir(dir));
}
if (
newDirectories.size !== this.directories.size ||
![...newDirectories].every((d) => this.directories.has(d))
) {
this.directories = newDirectories;
this.notifyDirectoriesChanged();
}
}