mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
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:
1 parent
8064138482
commit
f874aeb96a
3 files changed
+278
-15
No files matched your search
+47
-1
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user