feat(server): add McpServer.applyConfig() to reload configuration (#3051)

Adds `McpServer.applyConfig()` to apply reloadable configuration changes
to a running MCP server without restarting (`McpServer.applyConfig()`
has no production caller yet; it will be called by the config watcher in
the next PR in the stack).

- Acquires the tool mutex, merges reloadable options onto the startup
`ServerConfig`, and builds updated tool definitions before committing.
- Commits the new `ServerConfig` atomically, updates `ToolHandler`s and
MCP `RegisteredTool`s in place (emitting at most one debounced
`notifications/tools/list_changed`), refreshes combined filesystem
roots, and runs `McpContext.onConfigChanged()`.

<sub>Stack created with <a
href="https://github.com/github/gh-stack">GitHub Stacks CLI</a> • <a
href="https://gh.io/stacks-feedback">Give Feedback 💬</a></sub>
This commit is contained in:
Nikolay Vitkov authored and GitHub committed 2026-10-19 16:48:10 +00:00
1 parent 8064138482
commit f874aeb96a
3 files changed
+278 -15

No files matched your search

+47 -1
View File
@@ -9,7 +9,7 @@ import path from 'node:path';
import {pathToFileURL} from 'node:url';
import {BrowserManager} from './BrowserManager.js';
import {type ParsedArguments} from './config/ConfigParser.js';
import type {ParsedArguments} from './config/ConfigParser.js';
import {DEFAULT_FILESYSTEM_ROOT} from './config/mcp-options.js';
import {ServerConfig} from './config/ServerConfig.js';
import {loadIssueDescriptions} from './devtools/issueDescriptions.js';
@@ -31,6 +31,7 @@ import {
import {ToolHandler} from './ToolHandler.js';
import type {AnyToolDefinition} from './tools/ToolDefinition.js';
import {createTools} from './tools/tools.js';
import {logCruxDisclaimer} from './utils/disclaimers.js';
import {logger} from './utils/logger.js';
import {VERSION} from './version.js';
@@ -168,6 +169,51 @@ export class McpServer {
return await toolHandler.handle(args);
}
/**
* Applies a new config to the running server. Only reloadable options are
* applied, all other options keep the value the server was started with.
* Tools are updated in place and only tools whose listing changed notify
* the client, which results in at most one `tools/list_changed`
* notification.
*/
async applyConfig(config: ServerConfig): Promise<void> {
using guard = await this.#toolMutex.acquire();
const previousArgs = this.#config.resolved;
const nextConfig = this.#config.mergeReloadable(config);
const nextArgs = nextConfig.resolved;
// Build everything before committing so that a throwing tool factory
// leaves the server untouched.
const updates: Array<{
entry: ToolEntry;
tool: AnyToolDefinition;
}> = [];
for (const tool of createTools(nextArgs)) {
const entry = this.#tools.get(tool.name);
if (entry) {
updates.push({entry, tool});
}
}
this.#config.commit(nextConfig);
for (const {entry, tool} of updates) {
if (entry.handler.update(tool)) {
entry.registeredTool.update({
description: tool.description,
paramsSchema: entry.handler.registeredInputSchema,
annotations: tool.annotations,
enabled: !entry.handler.disabled,
});
}
}
if (!previousArgs.performanceCrux || previousArgs.slim) {
logCruxDisclaimer(nextArgs);
}
this.#context.setRoots(this.#combinedRoots());
await this.#context.onConfigChanged(previousArgs);
}
/**
* Closes the MCP connection and disposes internal context/listeners.
*/
+9 -5
View File
@@ -6,6 +6,14 @@
import type {ParsedArguments} from '../config/ConfigParser.js';
export function logCruxDisclaimer(args: ParsedArguments): void {
if (!args.slim && args.performanceCrux) {
console.error(
`Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`,
);
}
}
/**
* Prints the privacy disclaimers to stderr. Both the server and the CLI
* client call it. The CLI must not load `src/index.ts`, so this function
@@ -18,11 +26,7 @@ debug, and modify any data in the browser or DevTools.
Avoid sharing sensitive or personal information that you do not want to share with MCP clients.`,
);
if (!args.slim && args.performanceCrux) {
console.error(
`Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.`,
);
}
logCruxDisclaimer(args);
if (!args.slim && args.usageStatistics) {
console.error(
+222 -9
View File
@@ -8,6 +8,7 @@ import assert from 'node:assert';
import fs from 'node:fs';
import path from 'node:path';
import {afterEach, describe, it} from 'node:test';
import {pathToFileURL} from 'node:url';
import {Client, InMemoryTransport} from '@modelcontextprotocol/client';
import sinon from 'sinon';
@@ -53,7 +54,31 @@ describe('McpServer', () => {
const config = parseConfig(extraArgs);
const server = await McpServer.from(config, {browserManager});
return {server, browserManager, context};
return {server, browserManager, context, config};
}
async function connectClient(server: McpServer): Promise<Client> {
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({name: 'test', version: '1.0.0'});
await client.connect(clientTransport);
return client;
}
async function listServerTools(server: McpServer) {
const client = await connectClient(server);
try {
const {tools} = await client.listTools();
return tools;
} finally {
await client.close();
}
}
async function listServerToolNames(server: McpServer): Promise<string[]> {
const tools = await listServerTools(server);
return tools.map(tool => tool.name);
}
describe('callTool', () => {
@@ -247,16 +272,9 @@ describe('McpServer', () => {
describe('tool list', () => {
async function listToolNames(extraArgs: string[] = []): Promise<string[]> {
const {server} = await createTestServer(extraArgs);
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({name: 'test', version: '1.0.0'});
await client.connect(clientTransport);
try {
const {tools} = await client.listTools();
return tools.map(tool => tool.name).sort();
return (await listServerToolNames(server)).sort();
} finally {
await client.close();
await server.close();
}
}
@@ -315,6 +333,201 @@ describe('McpServer', () => {
assert.ok(!names.includes('evaluate_script'));
});
});
describe('applyConfig', () => {
it('enables tools that were disabled', async () => {
const {server} = await createTestServer();
assert.ok(
!(await listServerToolNames(server)).includes(
'get_heapsnapshot_summary',
),
);
await server.applyConfig(parseConfig(['--memoryDebugging']));
assert.ok(
(await listServerToolNames(server)).includes(
'get_heapsnapshot_summary',
),
);
});
it('toggles slim mode', async () => {
const {server} = await createTestServer();
await server.applyConfig(parseConfig(['--slim']));
const slimToolNames = await listServerToolNames(server);
assert.ok(!slimToolNames.includes('navigate_page'));
assert.ok(slimToolNames.includes('navigate'));
});
it('disables tools that were enabled', async () => {
const {server} = await createTestServer();
await server.applyConfig(parseConfig(['--no-category-network']));
assert.ok(
!(await listServerToolNames(server)).includes('list_network_requests'),
);
});
it('updates the listed tool schema when options change', async () => {
const {server} = await createTestServer();
await server.applyConfig(parseConfig(['--no-page-id-routing']));
const tools = await listServerTools(server);
const consoleTool = tools.find(
tool => tool.name === 'list_console_messages',
);
assert.ok(consoleTool);
assert.ok(!('pageId' in (consoleTool.inputSchema.properties ?? {})));
});
it('uses the new arguments for tool calls', async () => {
const {server, browserManager} = await createTestServer();
await server.applyConfig(parseConfig(['--no-javascript-evaluation']));
const result = await server.callTool('evaluate_script', {
function: '() => 1',
pageId: 1,
});
assert.strictEqual(result.isError, true);
const firstBlock = result.content[0];
assert.strictEqual(firstBlock.type, 'text');
assert.match(firstBlock.text, /requires flag --javascriptEvaluation/);
sinon.assert.notCalled(browserManager.ensureBrowser);
});
it('validates MCP tool calls queued behind applyConfig against the updated config', async () => {
const {server, context} = await createTestServer();
const client = await connectClient(server);
try {
const {promise: firstBrowser, resolve: releaseFirstCall} =
Promise.withResolvers<
ReturnType<typeof createMockPuppeteerBrowser>
>();
const {promise: firstCallEntered, resolve: markFirstCallEntered} =
Promise.withResolvers<void>();
context.ensureBrowser.onFirstCall().callsFake(async () => {
markFirstCallEntered();
return await firstBrowser;
});
const firstCall = client.callTool({name: 'list_pages', arguments: {}});
await firstCallEntered;
const applyPromise = server.applyConfig(
parseConfig(['--no-page-id-routing']),
);
const secondCall = client.callTool({
name: 'list_console_messages',
arguments: {pageId: 1},
});
releaseFirstCall(createMockPuppeteerBrowser());
await firstCall;
await applyPromise;
const secondResult = await secondCall;
assert.strictEqual(secondResult.isError, true);
const content = secondResult.content;
assert.ok(Array.isArray(content));
const firstBlock = content[0];
assert.strictEqual(firstBlock.type, 'text');
assert.match(
firstBlock.text,
/^Input validation error: Invalid arguments for tool list_console_messages: .*pageId/,
);
} finally {
await client.close();
}
});
it('commits the shared config, updates roots, and notifies the context with previous args', async () => {
const {server, context, config} = await createTestServer([
'--blockedUrlPattern',
'https://example.com/*',
]);
const previousArgs = config.resolved;
let cruxWhenNotified: boolean | undefined;
context.onConfigChanged.callsFake(async () => {
cruxWhenNotified = config.resolved.performanceCrux;
});
const rootPath = path.resolve('/tmp/reloaded-workspace');
await server.applyConfig(
parseConfig(['--no-performance-crux', `--workspace=${rootPath}`]),
);
sinon.assert.calledWithExactly(context.setRoots.secondCall, [
{
uri: pathToFileURL(rootPath).href,
name: 'reloaded-workspace',
},
]);
sinon.assert.calledOnceWithExactly(context.onConfigChanged, previousArgs);
assert.strictEqual(cruxWhenNotified, false);
assert.deepStrictEqual(config.resolved.blockedUrlPattern, [
'https://example.com/*',
]);
});
it('logs the CrUX disclaimer only when CrUX becomes active', async () => {
const {server} = await createTestServer(['--no-performance-crux']);
const defaultConfig = parseConfig();
const errorStub = sinon.stub(console, 'error');
await server.applyConfig(defaultConfig);
sinon.assert.calledOnceWithExactly(
errorStub,
'Performance tools may send trace URLs to the Google CrUX API to fetch real-user experience data. To disable, run with --no-performance-crux.',
);
await server.applyConfig(defaultConfig);
sinon.assert.calledOnce(errorStub);
});
it('keeps restart-required options the server was started with', async () => {
const {server} = await createTestServer();
await server.applyConfig(parseConfig(['--categoryExtensions']));
assert.ok(
!(await listServerToolNames(server)).includes('install_extension'),
);
});
it('notifies the client only when a tool changed', async () => {
const {server} = await createTestServer();
const sendToolListChanged = sinon.spy(
server.server,
'sendToolListChanged',
);
await server.applyConfig(parseConfig());
sinon.assert.notCalled(sendToolListChanged);
await server.applyConfig(parseConfig(['--memoryDebugging']));
sinon.assert.called(sendToolListChanged);
});
it('does not connect a browser when experimentalIncludeAllPages changes before a browser connects', async () => {
const {server, browserManager} = await createTestServer([], {
mockContext: false,
});
await server.applyConfig(parseConfig(['--experimentalIncludeAllPages']));
sinon.assert.notCalled(browserManager.ensureBrowser);
await server.close();
});
});
});
async function getToolsWithFilteredCategories(