chore: split context.ts into files (#284)

This commit is contained in:
Pavel Feldman 2025-04-28 16:14:16 -07:00 committed by GitHub
parent 26779ceb20
commit 6e76d5e550
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 344 additions and 289 deletions

5
config.d.ts vendored
View File

@ -45,6 +45,11 @@ export type Config = {
* Chrome DevTools Protocol endpoint to connect to an existing browser instance in case of Chromium family browsers.
*/
cdpEndpoint?: string;
/**
* Remote endpoint to connect to an existing Playwright server.
*/
remoteEndpoint?: string;
},
server?: {

86
src/config.ts Normal file
View File

@ -0,0 +1,86 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import net from 'net';
import os from 'os';
import type { Config } from '../config';
import type { LaunchOptions, BrowserContextOptions } from 'playwright';
export type BrowserOptions = {
browserName: 'chromium' | 'firefox' | 'webkit';
launchOptions: LaunchOptions;
contextOptions: BrowserContextOptions;
};
export async function toBrowserOptions(config: Config): Promise<BrowserOptions> {
let browserName: 'chromium' | 'firefox' | 'webkit';
let channel: string | undefined;
switch (config.browser?.type) {
case 'chrome':
case 'chrome-beta':
case 'chrome-canary':
case 'chrome-dev':
case 'chromium':
case 'msedge':
case 'msedge-beta':
case 'msedge-canary':
case 'msedge-dev':
browserName = 'chromium';
channel = config.browser.type;
break;
case 'firefox':
browserName = 'firefox';
break;
case 'webkit':
browserName = 'webkit';
break;
default:
browserName = 'chromium';
channel = 'chrome';
}
const launchOptions: LaunchOptions = {
headless: !!(config.browser?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
channel,
executablePath: config.browser?.executablePath,
...{ assistantMode: true },
};
const contextOptions: BrowserContextOptions = {
viewport: null,
};
if (browserName === 'chromium')
(launchOptions as any).webSocketPort = await findFreePort();
return {
browserName,
launchOptions,
contextOptions,
};
}
async function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}

View File

@ -14,26 +14,21 @@
* limitations under the License.
*/
import net from 'net';
import fs from 'fs';
import path from 'path';
import os from 'os';
import * as playwright from 'playwright';
import yaml from 'yaml';
import { waitForCompletion } from './tools/utils';
import { ManualPromise } from './manualPromise';
import { toBrowserOptions } from './config';
import { Tab } from './tab';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
export type ContextOptions = {
browserName?: 'chromium' | 'firefox' | 'webkit';
userDataDir: string;
launchOptions?: playwright.LaunchOptions & playwright.BrowserContextOptions;
cdpEndpoint?: string;
remoteEndpoint?: string;
};
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
import type { Config } from '../config';
import type { BrowserOptions } from './config';
type PendingAction = {
dialogShown: ManualPromise<void>;
@ -41,7 +36,7 @@ type PendingAction = {
export class Context {
readonly tools: Tool[];
readonly options: ContextOptions;
readonly config: Config;
private _browser: playwright.Browser | undefined;
private _browserContext: playwright.BrowserContext | undefined;
private _createBrowserContextPromise: Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> | undefined;
@ -50,9 +45,9 @@ export class Context {
private _modalStates: (ModalState & { tab: Tab })[] = [];
private _pendingAction: PendingAction | undefined;
constructor(tools: Tool[], options: ContextOptions) {
constructor(tools: Tool[], config: Config) {
this.tools = tools;
this.options = options;
this.config = config;
}
modalStates(): ModalState[] {
@ -290,205 +285,58 @@ ${code.join('\n')}
}
private async _innerCreateBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
if (this.options.browserName === 'chromium')
(this.options.launchOptions as any).webSocketPort = await findFreePort();
const browserOptions = await toBrowserOptions(this.config);
if (this.options.remoteEndpoint) {
const url = new URL(this.options.remoteEndpoint);
if (this.options.browserName)
url.searchParams.set('browser', this.options.browserName);
if (this.options.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this.options.launchOptions));
const browser = await playwright[this.options.browserName ?? 'chromium'].connect(String(url));
if (this.config.browser?.remoteEndpoint) {
const url = new URL(this.config.browser?.remoteEndpoint);
if (browserOptions.browserName)
url.searchParams.set('browser', browserOptions.browserName);
if (browserOptions.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(browserOptions.launchOptions));
const browser = await playwright[browserOptions.browserName ?? 'chromium'].connect(String(url));
const browserContext = await browser.newContext();
return { browser, browserContext };
}
if (this.options.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.options.cdpEndpoint);
if (this.config.browser?.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this.config.browser?.cdpEndpoint);
const browserContext = browser.contexts()[0];
return { browser, browserContext };
}
const browserContext = await this._launchPersistentContext();
const browserContext = await launchPersistentContext(this.config.browser?.userDataDir, browserOptions);
return { browserContext };
}
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
try {
const browserType = this.options.browserName ? playwright[this.options.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(this.options.userDataDir, this.options.launchOptions);
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throw error;
}
}
}
export class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _console: playwright.ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
private _snapshot: PageSnapshot | undefined;
private _onPageClose: (tab: Tab) => void;
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
this.context = context;
this.page = page;
this._onPageClose = onPageClose;
page.on('console', event => this._console.push(event));
page.on('request', request => this._requests.set(request, null));
page.on('response', response => this._requests.set(response.request(), response));
page.on('framenavigated', frame => {
if (!frame.parentFrame())
this._clearCollectedArtifacts();
});
page.on('close', () => this._onClose());
page.on('filechooser', chooser => {
this.context.setModalState({
type: 'fileChooser',
description: 'File chooser',
fileChooser: chooser,
}, this);
});
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
}
private _clearCollectedArtifacts() {
this._console.length = 0;
this._requests.clear();
}
private _onClose() {
this._clearCollectedArtifacts();
this._onPageClose(this);
}
async navigate(url: string) {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
// Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
}
hasSnapshot(): boolean {
return !!this._snapshot;
}
snapshotOrDie(): PageSnapshot {
if (!this._snapshot)
throw new Error('No snapshot available');
return this._snapshot;
}
console(): playwright.ConsoleMessage[] {
return this._console;
}
requests(): Map<playwright.Request, playwright.Response | null> {
return this._requests;
}
async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page);
}
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}
class PageSnapshot {
private _frameLocators: PageOrFrameLocator[] = [];
private _text!: string;
async function launchPersistentContext(userDataDir: string | undefined, browserOptions: BrowserOptions): Promise<playwright.BrowserContext> {
userDataDir = userDataDir ?? await createUserDataDir(browserOptions.browserName);
constructor() {
}
static async create(page: playwright.Page): Promise<PageSnapshot> {
const snapshot = new PageSnapshot();
await snapshot._build(page);
return snapshot;
}
text(): string {
return this._text;
}
private async _build(page: playwright.Page) {
const yamlDocument = await this._snapshotFrame(page);
this._text = [
`- Page Snapshot`,
'```yaml',
yamlDocument.toString({ indentSeq: false }).trim(),
'```',
].join('\n');
}
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
const frameIndex = this._frameLocators.push(frame) - 1;
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
const snapshot = yaml.parseDocument(snapshotString);
const visit = async (node: any): Promise<unknown> => {
if (yaml.isPair(node)) {
await Promise.all([
visit(node.key).then(k => node.key = k),
visit(node.value).then(v => node.value = v)
]);
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
node.items = await Promise.all(node.items.map(visit));
} else if (yaml.isScalar(node)) {
if (typeof node.value === 'string') {
const value = node.value;
if (frameIndex > 0)
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
if (value.startsWith('iframe ')) {
const ref = value.match(/\[ref=(.*)\]/)?.[1];
if (ref) {
try {
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
return snapshot.createPair(node.value, childSnapshot);
} catch (error) {
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
}
}
}
}
}
return node;
};
await visit(snapshot.contents);
return snapshot;
}
refLocator(ref: string): playwright.Locator {
let frame = this._frameLocators[0];
const match = ref.match(/^f(\d+)(.*)/);
if (match) {
const frameIndex = parseInt(match[1], 10);
frame = this._frameLocators[frameIndex];
ref = match[2];
}
if (!frame)
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
return frame.locator(`aria-ref=${ref}`);
try {
const browserType = browserOptions.browserName ? playwright[browserOptions.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(userDataDir, browserOptions.launchOptions);
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throw error;
}
}
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
}
async function findFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on('error', reject);
});
}

View File

@ -14,10 +14,6 @@
* limitations under the License.
*/
import path from 'path';
import os from 'os';
import fs from 'fs';
import { createServerWithTools } from './server';
import common from './tools/common';
import console from './tools/console';
@ -35,7 +31,6 @@ import screen from './tools/screen';
import type { Tool } from './tools/tool';
import type { Config } from '../config';
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
import type { LaunchOptions, BrowserContextOptions } from 'playwright';
const snapshotTools: Tool<any>[] = [
...common(true),
@ -67,67 +62,12 @@ const screenshotTools: Tool<any>[] = [
const packageJSON = require('../package.json');
export async function createServer(config?: Config): Promise<Server> {
let browserName: 'chromium' | 'firefox' | 'webkit';
let channel: string | undefined;
switch (config?.browser?.type) {
case 'chrome':
case 'chrome-beta':
case 'chrome-canary':
case 'chrome-dev':
case 'chromium':
case 'msedge':
case 'msedge-beta':
case 'msedge-canary':
case 'msedge-dev':
browserName = 'chromium';
channel = config.browser.type;
break;
case 'firefox':
browserName = 'firefox';
break;
case 'webkit':
browserName = 'webkit';
break;
default:
browserName = 'chromium';
channel = 'chrome';
}
const userDataDir = config?.browser?.userDataDir ?? await createUserDataDir(browserName);
const launchOptions: LaunchOptions & BrowserContextOptions = {
headless: !!(config?.browser?.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
channel,
executablePath: config?.browser?.executablePath,
viewport: null,
...{ assistantMode: true },
};
const allTools = config?.vision ? screenshotTools : snapshotTools;
const tools = allTools.filter(tool => !config?.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
export async function createServer(config: Config = {}): Promise<Server> {
const allTools = config.vision ? screenshotTools : snapshotTools;
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
return createServerWithTools({
name: 'Playwright',
version: packageJSON.version,
tools,
resources: [],
browserName,
userDataDir,
launchOptions,
cdpEndpoint: config?.browser?.cdpEndpoint,
});
}
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
else if (process.platform === 'darwin')
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
else if (process.platform === 'win32')
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}, config);
}

101
src/pageSnapshot.ts Normal file
View File

@ -0,0 +1,101 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as playwright from 'playwright';
import yaml from 'yaml';
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
export class PageSnapshot {
private _frameLocators: PageOrFrameLocator[] = [];
private _text!: string;
constructor() {
}
static async create(page: playwright.Page): Promise<PageSnapshot> {
const snapshot = new PageSnapshot();
await snapshot._build(page);
return snapshot;
}
text(): string {
return this._text;
}
private async _build(page: playwright.Page) {
const yamlDocument = await this._snapshotFrame(page);
this._text = [
`- Page Snapshot`,
'```yaml',
yamlDocument.toString({ indentSeq: false }).trim(),
'```',
].join('\n');
}
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
const frameIndex = this._frameLocators.push(frame) - 1;
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
const snapshot = yaml.parseDocument(snapshotString);
const visit = async (node: any): Promise<unknown> => {
if (yaml.isPair(node)) {
await Promise.all([
visit(node.key).then(k => node.key = k),
visit(node.value).then(v => node.value = v)
]);
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
node.items = await Promise.all(node.items.map(visit));
} else if (yaml.isScalar(node)) {
if (typeof node.value === 'string') {
const value = node.value;
if (frameIndex > 0)
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
if (value.startsWith('iframe ')) {
const ref = value.match(/\[ref=(.*)\]/)?.[1];
if (ref) {
try {
const childSnapshot = await this._snapshotFrame(frame.frameLocator(`aria-ref=${ref}`));
return snapshot.createPair(node.value, childSnapshot);
} catch (error) {
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
}
}
}
}
}
return node;
};
await visit(snapshot.contents);
return snapshot;
}
refLocator(ref: string): playwright.Locator {
let frame = this._frameLocators[0];
const match = ref.match(/^f(\d+)(.*)/);
if (match) {
const frameIndex = parseInt(match[1], 10);
frame = this._frameLocators[frameIndex];
ref = match[2];
}
if (!frame)
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
return frame.locator(`aria-ref=${ref}`);
}
}

View File

@ -15,29 +15,26 @@
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context } from './context';
import type { Tool } from './tools/tool';
import type { Resource } from './resources/resource';
import type { ContextOptions } from './context';
import type { Config } from '../config';
type Options = ContextOptions & {
type MCPServerOptions = {
name: string;
version: string;
tools: Tool[];
resources: Resource[],
};
export function createServerWithTools(options: Options): Server {
const { name, version, tools, resources } = options;
const context = new Context(tools, options);
export function createServerWithTools(serverOptions: MCPServerOptions, config: Config): Server {
const { name, version, tools } = serverOptions;
const context = new Context(tools, config);
const server = new Server({ name, version }, {
capabilities: {
tools: {},
resources: {},
}
});
@ -51,10 +48,6 @@ export function createServerWithTools(options: Options): Server {
};
});
server.setRequestHandler(ListResourcesRequestSchema, async () => {
return { resources: resources.map(resource => resource.schema) };
});
server.setRequestHandler(CallToolRequestSchema, async request => {
const tool = tools.find(tool => tool.schema.name === request.params.name);
if (!tool) {
@ -87,15 +80,6 @@ export function createServerWithTools(options: Options): Server {
}
});
server.setRequestHandler(ReadResourceRequestSchema, async request => {
const resource = resources.find(resource => resource.schema.uri === request.params.uri);
if (!resource)
return { contents: [] };
const contents = await resource.read(context, request.params.uri);
return { contents };
});
const oldClose = server.close.bind(server);
server.close = async () => {

92
src/tab.ts Normal file
View File

@ -0,0 +1,92 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as playwright from 'playwright';
import { PageSnapshot } from './pageSnapshot';
import type { Context } from './context';
export class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _console: playwright.ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
private _snapshot: PageSnapshot | undefined;
private _onPageClose: (tab: Tab) => void;
constructor(context: Context, page: playwright.Page, onPageClose: (tab: Tab) => void) {
this.context = context;
this.page = page;
this._onPageClose = onPageClose;
page.on('console', event => this._console.push(event));
page.on('request', request => this._requests.set(request, null));
page.on('response', response => this._requests.set(response.request(), response));
page.on('framenavigated', frame => {
if (!frame.parentFrame())
this._clearCollectedArtifacts();
});
page.on('close', () => this._onClose());
page.on('filechooser', chooser => {
this.context.setModalState({
type: 'fileChooser',
description: 'File chooser',
fileChooser: chooser,
}, this);
});
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
page.setDefaultNavigationTimeout(60000);
page.setDefaultTimeout(5000);
}
private _clearCollectedArtifacts() {
this._console.length = 0;
this._requests.clear();
}
private _onClose() {
this._clearCollectedArtifacts();
this._onPageClose(this);
}
async navigate(url: string) {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
// Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
}
hasSnapshot(): boolean {
return !!this._snapshot;
}
snapshotOrDie(): PageSnapshot {
if (!this._snapshot)
throw new Error('No snapshot available');
return this._snapshot;
}
console(): playwright.ConsoleMessage[] {
return this._console;
}
requests(): Map<playwright.Request, playwright.Response | null> {
return this._requests;
}
async captureSnapshot() {
this._snapshot = await PageSnapshot.create(this.page);
}
}

View File

@ -19,6 +19,7 @@ import path from 'path';
import { z } from 'zod';
import { defineTool } from './tool';
import { toBrowserOptions } from '../config';
const install = defineTool({
capability: 'install',
@ -29,7 +30,8 @@ const install = defineTool({
},
handle: async context => {
const channel = context.options.launchOptions?.channel ?? context.options.browserName ?? 'chrome';
const browserOptions = await toBrowserOptions(context.config);
const channel = browserOptions.launchOptions?.channel ?? browserOptions.browserName ?? 'chrome';
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
const child = fork(cli, ['install', channel], {
stdio: 'pipe',

View File

@ -20,11 +20,10 @@ import os from 'os';
import { z } from 'zod';
import { sanitizeForFilePath } from './utils';
import { generateLocator } from '../context';
import { defineTool } from './tool';
import * as javascript from '../javascript';
import type * as playwright from 'playwright';
import { defineTool } from './tool';
const snapshot = defineTool({
capability: 'core',
@ -268,6 +267,9 @@ const screenshot = defineTool({
}
});
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
}
export default [
snapshot,

View File

@ -74,11 +74,6 @@ test('test vision tool list', async ({ visionClient }) => {
]));
});
test('test resources list', async ({ client }) => {
const { resources } = await client.listResources();
expect(resources).toEqual([]);
});
test('test capabilities', async ({ startClient }) => {
const client = await startClient({
args: ['--caps="core"'],