@softeria/ms-365-mcp-server 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,11 +4,194 @@ import { z } from "zod";
4
4
  import { readFileSync } from "fs";
5
5
  import path from "path";
6
6
  import { fileURLToPath } from "url";
7
+ import { TOOL_CATEGORIES } from "./tool-categories.js";
7
8
  const __filename = fileURLToPath(import.meta.url);
8
9
  const __dirname = path.dirname(__filename);
9
10
  const endpointsData = JSON.parse(
10
11
  readFileSync(path.join(__dirname, "endpoints.json"), "utf8")
11
12
  );
13
+ async function executeGraphTool(tool, config, graphClient, params) {
14
+ logger.info(`Tool ${tool.alias} called with params: ${JSON.stringify(params)}`);
15
+ try {
16
+ const parameterDefinitions = tool.parameters || [];
17
+ let path2 = tool.path;
18
+ const queryParams = {};
19
+ const headers = {};
20
+ let body = null;
21
+ for (const [paramName, paramValue] of Object.entries(params)) {
22
+ if (["fetchAllPages", "includeHeaders", "excludeResponse", "timezone"].includes(paramName)) {
23
+ continue;
24
+ }
25
+ const odataParams = [
26
+ "filter",
27
+ "select",
28
+ "expand",
29
+ "orderby",
30
+ "skip",
31
+ "top",
32
+ "count",
33
+ "search",
34
+ "format"
35
+ ];
36
+ const normalizedParamName = paramName.startsWith("$") ? paramName.slice(1) : paramName;
37
+ const isOdataParam = odataParams.includes(normalizedParamName.toLowerCase());
38
+ const fixedParamName = isOdataParam ? `$${normalizedParamName.toLowerCase()}` : paramName;
39
+ const paramDef = parameterDefinitions.find(
40
+ (p) => p.name === paramName || isOdataParam && p.name === normalizedParamName
41
+ );
42
+ if (paramDef) {
43
+ switch (paramDef.type) {
44
+ case "Path":
45
+ path2 = path2.replace(`{${paramName}}`, encodeURIComponent(paramValue)).replace(`:${paramName}`, encodeURIComponent(paramValue));
46
+ break;
47
+ case "Query":
48
+ queryParams[fixedParamName] = `${paramValue}`;
49
+ break;
50
+ case "Body":
51
+ if (paramDef.schema) {
52
+ const parseResult = paramDef.schema.safeParse(paramValue);
53
+ if (!parseResult.success) {
54
+ const wrapped = { [paramName]: paramValue };
55
+ const wrappedResult = paramDef.schema.safeParse(wrapped);
56
+ if (wrappedResult.success) {
57
+ logger.info(
58
+ `Auto-corrected parameter '${paramName}': AI passed nested field directly, wrapped it as {${paramName}: ...}`
59
+ );
60
+ body = wrapped;
61
+ } else {
62
+ body = paramValue;
63
+ }
64
+ } else {
65
+ body = paramValue;
66
+ }
67
+ } else {
68
+ body = paramValue;
69
+ }
70
+ break;
71
+ case "Header":
72
+ headers[fixedParamName] = `${paramValue}`;
73
+ break;
74
+ }
75
+ } else if (paramName === "body") {
76
+ body = paramValue;
77
+ logger.info(`Set body param: ${JSON.stringify(body)}`);
78
+ }
79
+ }
80
+ if (config?.supportsTimezone && params.timezone) {
81
+ headers["Prefer"] = `outlook.timezone="${params.timezone}"`;
82
+ logger.info(`Setting timezone header: Prefer: outlook.timezone="${params.timezone}"`);
83
+ }
84
+ if (Object.keys(queryParams).length > 0) {
85
+ const queryString = Object.entries(queryParams).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
86
+ path2 = `${path2}${path2.includes("?") ? "&" : "?"}${queryString}`;
87
+ }
88
+ const options = {
89
+ method: tool.method.toUpperCase(),
90
+ headers
91
+ };
92
+ if (options.method !== "GET" && body) {
93
+ options.body = typeof body === "string" ? body : JSON.stringify(body);
94
+ }
95
+ const isProbablyMediaContent = tool.errors?.some((error) => error.description === "Retrieved media content") || path2.endsWith("/content");
96
+ if (config?.returnDownloadUrl && path2.endsWith("/content")) {
97
+ path2 = path2.replace(/\/content$/, "");
98
+ logger.info(
99
+ `Auto-returning download URL for ${tool.alias} (returnDownloadUrl=true in endpoints.json)`
100
+ );
101
+ } else if (isProbablyMediaContent) {
102
+ options.rawResponse = true;
103
+ }
104
+ if (params.includeHeaders === true) {
105
+ options.includeHeaders = true;
106
+ }
107
+ if (params.excludeResponse === true) {
108
+ options.excludeResponse = true;
109
+ }
110
+ logger.info(`Making graph request to ${path2} with options: ${JSON.stringify(options)}`);
111
+ let response = await graphClient.graphRequest(path2, options);
112
+ const fetchAllPages = params.fetchAllPages === true;
113
+ if (fetchAllPages && response?.content?.[0]?.text) {
114
+ try {
115
+ let combinedResponse = JSON.parse(response.content[0].text);
116
+ let allItems = combinedResponse.value || [];
117
+ let nextLink = combinedResponse["@odata.nextLink"];
118
+ let pageCount = 1;
119
+ while (nextLink && pageCount < 100) {
120
+ logger.info(`Fetching page ${pageCount + 1} from: ${nextLink}`);
121
+ const url = new URL(nextLink);
122
+ const nextPath = url.pathname.replace("/v1.0", "");
123
+ const nextOptions = { ...options };
124
+ const nextQueryParams = {};
125
+ for (const [key, value] of url.searchParams.entries()) {
126
+ nextQueryParams[key] = value;
127
+ }
128
+ nextOptions.queryParams = nextQueryParams;
129
+ const nextResponse = await graphClient.graphRequest(nextPath, nextOptions);
130
+ if (nextResponse?.content?.[0]?.text) {
131
+ const nextJsonResponse = JSON.parse(nextResponse.content[0].text);
132
+ if (nextJsonResponse.value && Array.isArray(nextJsonResponse.value)) {
133
+ allItems = allItems.concat(nextJsonResponse.value);
134
+ }
135
+ nextLink = nextJsonResponse["@odata.nextLink"];
136
+ pageCount++;
137
+ } else {
138
+ break;
139
+ }
140
+ }
141
+ if (pageCount >= 100) {
142
+ logger.warn(`Reached maximum page limit (100) for pagination`);
143
+ }
144
+ combinedResponse.value = allItems;
145
+ if (combinedResponse["@odata.count"]) {
146
+ combinedResponse["@odata.count"] = allItems.length;
147
+ }
148
+ delete combinedResponse["@odata.nextLink"];
149
+ response.content[0].text = JSON.stringify(combinedResponse);
150
+ logger.info(
151
+ `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
152
+ );
153
+ } catch (e) {
154
+ logger.error(`Error during pagination: ${e}`);
155
+ }
156
+ }
157
+ if (response?.content?.[0]?.text) {
158
+ const responseText = response.content[0].text;
159
+ logger.info(`Response size: ${responseText.length} characters`);
160
+ try {
161
+ const jsonResponse = JSON.parse(responseText);
162
+ if (jsonResponse.value && Array.isArray(jsonResponse.value)) {
163
+ logger.info(`Response contains ${jsonResponse.value.length} items`);
164
+ }
165
+ if (jsonResponse["@odata.nextLink"]) {
166
+ logger.info(`Response has pagination nextLink: ${jsonResponse["@odata.nextLink"]}`);
167
+ }
168
+ } catch {
169
+ }
170
+ }
171
+ const content = response.content.map((item) => ({
172
+ type: "text",
173
+ text: item.text
174
+ }));
175
+ return {
176
+ content,
177
+ _meta: response._meta,
178
+ isError: response.isError
179
+ };
180
+ } catch (error) {
181
+ logger.error(`Error in tool ${tool.alias}: ${error.message}`);
182
+ return {
183
+ content: [
184
+ {
185
+ type: "text",
186
+ text: JSON.stringify({
187
+ error: `Error in tool ${tool.alias}: ${error.message}`
188
+ })
189
+ }
190
+ ],
191
+ isError: true
192
+ };
193
+ }
194
+ }
12
195
  function registerGraphTools(server, graphClient, readOnly = false, enabledToolsPattern, orgMode = false) {
13
196
  let enabledToolsRegex;
14
197
  if (enabledToolsPattern) {
@@ -19,18 +202,24 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
19
202
  logger.error(`Invalid tool filter regex pattern: ${enabledToolsPattern}. Ignoring filter.`);
20
203
  }
21
204
  }
205
+ let registeredCount = 0;
206
+ let skippedCount = 0;
207
+ let failedCount = 0;
22
208
  for (const tool of api.endpoints) {
23
209
  const endpointConfig = endpointsData.find((e) => e.toolName === tool.alias);
24
210
  if (!orgMode && endpointConfig && !endpointConfig.scopes && endpointConfig.workScopes) {
25
211
  logger.info(`Skipping work account tool ${tool.alias} - not in org mode`);
212
+ skippedCount++;
26
213
  continue;
27
214
  }
28
215
  if (readOnly && tool.method.toUpperCase() !== "GET") {
29
216
  logger.info(`Skipping write operation ${tool.alias} in read-only mode`);
217
+ skippedCount++;
30
218
  continue;
31
219
  }
32
220
  if (enabledToolsRegex && !enabledToolsRegex.test(tool.alias)) {
33
221
  logger.info(`Skipping tool ${tool.alias} - doesn't match filter pattern`);
222
+ skippedCount++;
34
223
  continue;
35
224
  }
36
225
  const paramSchema = {};
@@ -55,218 +244,133 @@ function registerGraphTools(server, graphClient, readOnly = false, enabledToolsP
55
244
 
56
245
  \u{1F4A1} TIP: ${endpointConfig.llmTip}`;
57
246
  }
58
- server.tool(
59
- tool.alias,
60
- toolDescription,
61
- paramSchema,
62
- {
63
- title: tool.alias,
64
- readOnlyHint: tool.method.toUpperCase() === "GET"
65
- },
66
- async (params) => {
67
- logger.info(`Tool ${tool.alias} called with params: ${JSON.stringify(params)}`);
68
- try {
69
- logger.info(`params: ${JSON.stringify(params)}`);
70
- const parameterDefinitions = tool.parameters || [];
71
- let path2 = tool.path;
72
- const queryParams = {};
73
- const headers = {};
74
- let body = null;
75
- for (let [paramName, paramValue] of Object.entries(params)) {
76
- if (paramName === "fetchAllPages") {
77
- continue;
78
- }
79
- if (paramName === "includeHeaders") {
80
- continue;
81
- }
82
- if (paramName === "excludeResponse") {
83
- continue;
84
- }
85
- const odataParams = [
86
- "filter",
87
- "select",
88
- "expand",
89
- "orderby",
90
- "skip",
91
- "top",
92
- "count",
93
- "search",
94
- "format"
95
- ];
96
- const fixedParamName = odataParams.includes(paramName.toLowerCase()) ? `$${paramName.toLowerCase()}` : paramName;
97
- const paramDef = parameterDefinitions.find((p) => p.name === paramName);
98
- if (paramDef) {
99
- switch (paramDef.type) {
100
- case "Path":
101
- path2 = path2.replace(`{${paramName}}`, encodeURIComponent(paramValue)).replace(`:${paramName}`, encodeURIComponent(paramValue));
102
- break;
103
- case "Query":
104
- queryParams[fixedParamName] = `${paramValue}`;
105
- break;
106
- case "Body":
107
- if (paramDef.schema) {
108
- const parseResult = paramDef.schema.safeParse(paramValue);
109
- if (!parseResult.success) {
110
- const wrapped = { [paramName]: paramValue };
111
- const wrappedResult = paramDef.schema.safeParse(wrapped);
112
- if (wrappedResult.success) {
113
- logger.info(
114
- `Auto-corrected parameter '${paramName}': AI passed nested field directly, wrapped it as {${paramName}: ...}`
115
- );
116
- body = wrapped;
117
- } else {
118
- body = paramValue;
119
- }
120
- } else {
121
- body = paramValue;
122
- }
123
- } else {
124
- body = paramValue;
125
- }
126
- break;
127
- case "Header":
128
- headers[fixedParamName] = `${paramValue}`;
129
- break;
130
- }
131
- } else if (paramName === "body") {
132
- body = paramValue;
133
- logger.info(`Set body param: ${JSON.stringify(body)}`);
134
- }
135
- }
136
- if (endpointConfig?.supportsTimezone && params.timezone) {
137
- headers["Prefer"] = `outlook.timezone="${params.timezone}"`;
138
- logger.info(`Setting timezone header: Prefer: outlook.timezone="${params.timezone}"`);
139
- }
140
- if (Object.keys(queryParams).length > 0) {
141
- const queryString = Object.entries(queryParams).map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join("&");
142
- path2 = `${path2}${path2.includes("?") ? "&" : "?"}${queryString}`;
143
- }
144
- const options = {
145
- method: tool.method.toUpperCase(),
146
- headers
147
- };
148
- if (options.method !== "GET" && body) {
149
- options.body = typeof body === "string" ? body : JSON.stringify(body);
150
- }
151
- const isProbablyMediaContent = tool.errors?.some((error) => error.description === "Retrieved media content") || path2.endsWith("/content");
152
- if (endpointConfig?.returnDownloadUrl && path2.endsWith("/content")) {
153
- path2 = path2.replace(/\/content$/, "");
154
- logger.info(
155
- `Auto-returning download URL for ${tool.alias} (returnDownloadUrl=true in endpoints.json)`
156
- );
157
- } else if (isProbablyMediaContent) {
158
- options.rawResponse = true;
159
- }
160
- if (params.includeHeaders === true) {
161
- options.includeHeaders = true;
162
- }
163
- if (params.excludeResponse === true) {
164
- options.excludeResponse = true;
165
- }
166
- logger.info(`Making graph request to ${path2} with options: ${JSON.stringify(options)}`);
167
- let response = await graphClient.graphRequest(path2, options);
168
- const fetchAllPages = params.fetchAllPages === true;
169
- if (fetchAllPages && response && response.content && response.content.length > 0) {
170
- try {
171
- let combinedResponse = JSON.parse(response.content[0].text);
172
- let allItems = combinedResponse.value || [];
173
- let nextLink = combinedResponse["@odata.nextLink"];
174
- let pageCount = 1;
175
- while (nextLink) {
176
- logger.info(`Fetching page ${pageCount + 1} from: ${nextLink}`);
177
- const url = new URL(nextLink);
178
- const nextPath = url.pathname.replace("/v1.0", "");
179
- const nextOptions = { ...options };
180
- const nextQueryParams = {};
181
- for (const [key, value] of url.searchParams.entries()) {
182
- nextQueryParams[key] = value;
183
- }
184
- nextOptions.queryParams = nextQueryParams;
185
- const nextResponse = await graphClient.graphRequest(nextPath, nextOptions);
186
- if (nextResponse && nextResponse.content && nextResponse.content.length > 0) {
187
- const nextJsonResponse = JSON.parse(nextResponse.content[0].text);
188
- if (nextJsonResponse.value && Array.isArray(nextJsonResponse.value)) {
189
- allItems = allItems.concat(nextJsonResponse.value);
190
- }
191
- nextLink = nextJsonResponse["@odata.nextLink"];
192
- pageCount++;
193
- if (pageCount > 100) {
194
- logger.warn(`Reached maximum page limit (100) for pagination`);
195
- break;
196
- }
197
- } else {
198
- break;
199
- }
200
- }
201
- combinedResponse.value = allItems;
202
- if (combinedResponse["@odata.count"]) {
203
- combinedResponse["@odata.count"] = allItems.length;
204
- }
205
- delete combinedResponse["@odata.nextLink"];
206
- response.content[0].text = JSON.stringify(combinedResponse);
207
- logger.info(
208
- `Pagination complete: collected ${allItems.length} items across ${pageCount} pages`
209
- );
210
- } catch (e) {
211
- logger.error(`Error during pagination: ${e}`);
212
- }
247
+ try {
248
+ server.tool(
249
+ tool.alias,
250
+ toolDescription,
251
+ paramSchema,
252
+ {
253
+ title: tool.alias,
254
+ readOnlyHint: tool.method.toUpperCase() === "GET"
255
+ },
256
+ async (params) => executeGraphTool(tool, endpointConfig, graphClient, params)
257
+ );
258
+ registeredCount++;
259
+ } catch (error) {
260
+ logger.error(`Failed to register tool ${tool.alias}: ${error.message}`);
261
+ failedCount++;
262
+ }
263
+ }
264
+ logger.info(
265
+ `Tool registration complete: ${registeredCount} registered, ${skippedCount} skipped, ${failedCount} failed`
266
+ );
267
+ return registeredCount;
268
+ }
269
+ function buildToolsRegistry(readOnly, orgMode) {
270
+ const toolsMap = /* @__PURE__ */ new Map();
271
+ for (const tool of api.endpoints) {
272
+ const endpointConfig = endpointsData.find((e) => e.toolName === tool.alias);
273
+ if (!orgMode && endpointConfig && !endpointConfig.scopes && endpointConfig.workScopes) {
274
+ continue;
275
+ }
276
+ if (readOnly && tool.method.toUpperCase() !== "GET") {
277
+ continue;
278
+ }
279
+ toolsMap.set(tool.alias, { tool, config: endpointConfig });
280
+ }
281
+ return toolsMap;
282
+ }
283
+ function registerDiscoveryTools(server, graphClient, readOnly = false, orgMode = false) {
284
+ const toolsRegistry = buildToolsRegistry(readOnly, orgMode);
285
+ logger.info(`Discovery mode: ${toolsRegistry.size} tools available in registry`);
286
+ server.tool(
287
+ "search-tools",
288
+ `Search through ${toolsRegistry.size} available Microsoft Graph API tools. Use this to find tools by name, path, or description before executing them.`,
289
+ {
290
+ query: z.string().describe("Search query to filter tools (searches name, path, and description)").optional(),
291
+ category: z.string().describe(
292
+ "Filter by category: mail, calendar, files, contacts, tasks, onenote, search, users, excel"
293
+ ).optional(),
294
+ limit: z.number().describe("Maximum results to return (default: 20, max: 50)").optional()
295
+ },
296
+ {
297
+ title: "search-tools",
298
+ readOnlyHint: true
299
+ },
300
+ async ({ query, category, limit = 20 }) => {
301
+ const maxLimit = Math.min(limit, 50);
302
+ const results = [];
303
+ const queryLower = query?.toLowerCase();
304
+ const categoryDef = category ? TOOL_CATEGORIES[category] : void 0;
305
+ for (const [name, { tool, config }] of toolsRegistry) {
306
+ if (categoryDef && !categoryDef.pattern.test(name)) {
307
+ continue;
308
+ }
309
+ if (queryLower) {
310
+ const searchText = `${name} ${tool.path} ${tool.description || ""} ${config?.llmTip || ""}`.toLowerCase();
311
+ if (!searchText.includes(queryLower)) {
312
+ continue;
213
313
  }
214
- if (response && response.content && response.content.length > 0) {
215
- const responseText = response.content[0].text;
216
- const responseSize = responseText.length;
217
- logger.info(`Response size: ${responseSize} characters`);
218
- try {
219
- const jsonResponse = JSON.parse(responseText);
220
- if (jsonResponse.value && Array.isArray(jsonResponse.value)) {
221
- logger.info(`Response contains ${jsonResponse.value.length} items`);
222
- if (jsonResponse.value.length > 0 && jsonResponse.value[0].body) {
223
- logger.info(
224
- `First item has body field with size: ${JSON.stringify(jsonResponse.value[0].body).length} characters`
225
- );
226
- }
227
- }
228
- if (jsonResponse["@odata.nextLink"]) {
229
- logger.info(`Response has pagination nextLink: ${jsonResponse["@odata.nextLink"]}`);
230
- }
231
- const preview = responseText.substring(0, 500);
232
- logger.info(`Response preview: ${preview}${responseText.length > 500 ? "..." : ""}`);
233
- } catch {
234
- const preview = responseText.substring(0, 500);
235
- logger.info(
236
- `Response preview (non-JSON): ${preview}${responseText.length > 500 ? "..." : ""}`
237
- );
238
- }
314
+ }
315
+ results.push({
316
+ name,
317
+ method: tool.method.toUpperCase(),
318
+ path: tool.path,
319
+ description: tool.description || `${tool.method.toUpperCase()} ${tool.path}`
320
+ });
321
+ if (results.length >= maxLimit) break;
322
+ }
323
+ return {
324
+ content: [
325
+ {
326
+ type: "text",
327
+ text: JSON.stringify(
328
+ {
329
+ found: results.length,
330
+ total: toolsRegistry.size,
331
+ tools: results,
332
+ tip: "Use execute-tool with the tool name and required parameters to call any of these tools."
333
+ },
334
+ null,
335
+ 2
336
+ )
239
337
  }
240
- const content = response.content.map((item) => {
241
- const textContent = {
338
+ ]
339
+ };
340
+ }
341
+ );
342
+ server.tool(
343
+ "execute-tool",
344
+ "Execute a Microsoft Graph API tool by name. Use search-tools first to find available tools and their parameters.",
345
+ {
346
+ tool_name: z.string().describe('Name of the tool to execute (e.g., "list-mail-messages")'),
347
+ parameters: z.record(z.any()).describe("Parameters to pass to the tool as key-value pairs").optional()
348
+ },
349
+ {
350
+ title: "execute-tool",
351
+ readOnlyHint: false
352
+ },
353
+ async ({ tool_name, parameters = {} }) => {
354
+ const toolData = toolsRegistry.get(tool_name);
355
+ if (!toolData) {
356
+ return {
357
+ content: [
358
+ {
242
359
  type: "text",
243
- text: item.text
244
- };
245
- return textContent;
246
- });
247
- const result = {
248
- content,
249
- _meta: response._meta,
250
- isError: response.isError
251
- };
252
- return result;
253
- } catch (error) {
254
- logger.error(`Error in tool ${tool.alias}: ${error.message}`);
255
- const errorContent = {
256
- type: "text",
257
- text: JSON.stringify({
258
- error: `Error in tool ${tool.alias}: ${error.message}`
259
- })
260
- };
261
- return {
262
- content: [errorContent],
263
- isError: true
264
- };
265
- }
360
+ text: JSON.stringify({
361
+ error: `Tool not found: ${tool_name}`,
362
+ tip: "Use search-tools to find available tools."
363
+ })
364
+ }
365
+ ],
366
+ isError: true
367
+ };
266
368
  }
267
- );
268
- }
369
+ return executeGraphTool(toolData.tool, toolData.config, graphClient, parameters);
370
+ }
371
+ );
269
372
  }
270
373
  export {
374
+ registerDiscoveryTools,
271
375
  registerGraphTools
272
376
  };
package/dist/server.js CHANGED
@@ -6,7 +6,7 @@ import express from "express";
6
6
  import crypto from "crypto";
7
7
  import logger, { enableConsoleLogging } from "./logger.js";
8
8
  import { registerAuthTools } from "./auth-tools.js";
9
- import { registerGraphTools } from "./graph-tools.js";
9
+ import { registerGraphTools, registerDiscoveryTools } from "./graph-tools.js";
10
10
  import GraphClient from "./graph-client.js";
11
11
  import { buildScopesFromEndpoints } from "./auth.js";
12
12
  import { MicrosoftOAuthProvider } from "./oauth-provider.js";
@@ -33,13 +33,23 @@ class MicrosoftGraphServer {
33
33
  if (shouldRegisterAuthTools) {
34
34
  registerAuthTools(this.server, this.authManager);
35
35
  }
36
- registerGraphTools(
37
- this.server,
38
- this.graphClient,
39
- this.options.readOnly,
40
- this.options.enabledTools,
41
- this.options.orgMode
42
- );
36
+ if (this.options.discovery) {
37
+ logger.info("Discovery mode enabled (experimental) - registering discovery tool only");
38
+ registerDiscoveryTools(
39
+ this.server,
40
+ this.graphClient,
41
+ this.options.readOnly,
42
+ this.options.orgMode
43
+ );
44
+ } else {
45
+ registerGraphTools(
46
+ this.server,
47
+ this.graphClient,
48
+ this.options.readOnly,
49
+ this.options.enabledTools,
50
+ this.options.orgMode
51
+ );
52
+ }
43
53
  }
44
54
  async start() {
45
55
  if (this.options.v) {