mcp-multi-jira 0.1.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.
@@ -0,0 +1,189 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import PQueue from "p-queue";
5
+ import { MCP_SERVER_URL, MCP_SSE_URL, refreshTokensIfNeeded, } from "../oauth/atlassian.js";
6
+ import { debug, warn } from "../utils/log.js";
7
+ import { PACKAGE_VERSION } from "../version.js";
8
+ export class RemoteSession {
9
+ account;
10
+ client;
11
+ connected = false;
12
+ tokenStore;
13
+ scopes;
14
+ staticClientInfo;
15
+ queue;
16
+ tokens = null;
17
+ loadPromise = null;
18
+ refreshPromise = null;
19
+ constructor(account, tokenStore, scopes, staticClientInfo) {
20
+ this.account = account;
21
+ this.tokenStore = tokenStore;
22
+ this.scopes = scopes;
23
+ this.staticClientInfo = staticClientInfo;
24
+ this.client = this.createClient();
25
+ this.queue = new PQueue({ concurrency: 4 });
26
+ }
27
+ createClient() {
28
+ return new Client({ name: "mcp-jira", version: PACKAGE_VERSION });
29
+ }
30
+ async loadTokens() {
31
+ const stored = await this.tokenStore.get(this.account.alias);
32
+ if (!stored) {
33
+ throw new Error(`No tokens found for account ${this.account.alias}. Run login first.`);
34
+ }
35
+ this.tokens = stored;
36
+ }
37
+ async ensureTokensLoaded() {
38
+ if (this.tokens) {
39
+ return;
40
+ }
41
+ if (!this.loadPromise) {
42
+ this.loadPromise = this.loadTokens().finally(() => {
43
+ this.loadPromise = null;
44
+ });
45
+ }
46
+ await this.loadPromise;
47
+ }
48
+ refreshTokens() {
49
+ if (this.refreshPromise) {
50
+ return this.refreshPromise;
51
+ }
52
+ this.refreshPromise = (async () => {
53
+ const refreshed = await refreshTokensIfNeeded({
54
+ alias: this.account.alias,
55
+ tokenStore: this.tokenStore,
56
+ scopes: this.scopes,
57
+ staticClientInfo: this.staticClientInfo,
58
+ });
59
+ this.tokens = refreshed;
60
+ return refreshed;
61
+ })().finally(() => {
62
+ this.refreshPromise = null;
63
+ });
64
+ return this.refreshPromise;
65
+ }
66
+ tokenNeedsRefresh() {
67
+ if (!this.tokens) {
68
+ return true;
69
+ }
70
+ const now = Date.now();
71
+ return this.tokens.expiresAt < now + 5 * 60 * 1000;
72
+ }
73
+ async ensureValidTokens() {
74
+ await this.ensureTokensLoaded();
75
+ if (!this.tokenNeedsRefresh()) {
76
+ return;
77
+ }
78
+ if (!this.tokens?.refreshToken) {
79
+ throw new Error(`Tokens for ${this.account.alias} have expired and no refresh token is available.`);
80
+ }
81
+ await this.refreshTokens();
82
+ }
83
+ async connectStreamableHttp() {
84
+ if (!this.tokens) {
85
+ throw new Error("Missing tokens");
86
+ }
87
+ const transport = new StreamableHTTPClientTransport(new URL(MCP_SERVER_URL), {
88
+ requestInit: {
89
+ headers: {
90
+ authorization: `Bearer ${this.tokens.accessToken}`,
91
+ },
92
+ },
93
+ });
94
+ await this.client.connect(transport);
95
+ this.connected = true;
96
+ }
97
+ async connectSse() {
98
+ if (!this.tokens) {
99
+ throw new Error("Missing tokens");
100
+ }
101
+ const eventSourceInit = {
102
+ headers: {
103
+ authorization: `Bearer ${this.tokens.accessToken}`,
104
+ },
105
+ };
106
+ const transport = new SSEClientTransport(new URL(MCP_SSE_URL), {
107
+ requestInit: {
108
+ headers: {
109
+ authorization: `Bearer ${this.tokens.accessToken}`,
110
+ },
111
+ },
112
+ eventSourceInit,
113
+ });
114
+ await this.client.connect(transport);
115
+ this.connected = true;
116
+ }
117
+ async connect() {
118
+ await this.ensureValidTokens();
119
+ if (this.connected) {
120
+ return;
121
+ }
122
+ try {
123
+ await this.connectStreamableHttp();
124
+ debug(`[${this.account.alias}] Connected via Streamable HTTP`);
125
+ }
126
+ catch (err) {
127
+ warn(`[${this.account.alias}] Streamable HTTP failed, falling back to SSE: ${String(err)}`);
128
+ await this.connectSse();
129
+ }
130
+ }
131
+ async refreshAndReconnect() {
132
+ await this.ensureValidTokens();
133
+ await this.client.close();
134
+ this.client = this.createClient();
135
+ this.connected = false;
136
+ await this.connect();
137
+ }
138
+ shouldRefreshOnError(err) {
139
+ if (!err || typeof err !== "object") {
140
+ return false;
141
+ }
142
+ const code = err.code;
143
+ if (code === 401 || code === 403) {
144
+ return true;
145
+ }
146
+ const message = String(err);
147
+ return (message.toLowerCase().includes("unauthorized") ||
148
+ message.toLowerCase().includes("forbidden"));
149
+ }
150
+ async listTools() {
151
+ if (!this.connected) {
152
+ await this.connect();
153
+ }
154
+ const tools = [];
155
+ let cursor;
156
+ do {
157
+ const result = await this.client.listTools({ cursor });
158
+ tools.push(...result.tools);
159
+ cursor = result.nextCursor;
160
+ } while (cursor);
161
+ return tools;
162
+ }
163
+ async callTool(name, args) {
164
+ if (!this.connected) {
165
+ await this.connect();
166
+ }
167
+ return this.queue.add(async () => {
168
+ try {
169
+ return await this.client.callTool({
170
+ name,
171
+ arguments: args,
172
+ });
173
+ }
174
+ catch (err) {
175
+ if (this.shouldRefreshOnError(err)) {
176
+ await this.refreshAndReconnect();
177
+ return this.client.callTool({
178
+ name,
179
+ arguments: args,
180
+ });
181
+ }
182
+ throw err;
183
+ }
184
+ });
185
+ }
186
+ async close() {
187
+ await this.client.close();
188
+ }
189
+ }
@@ -0,0 +1,163 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
4
+ import PQueue from "p-queue";
5
+ import { MCP_SERVER_URL, MCP_SSE_URL, refreshTokensIfNeeded, } from "../oauth/atlassian.js";
6
+ import { debug, warn } from "../utils/log.js";
7
+ export class RemoteSession {
8
+ account;
9
+ tokenStore;
10
+ scopes;
11
+ staticClientInfo;
12
+ client;
13
+ connected = false;
14
+ queue;
15
+ tokens = null;
16
+ constructor(account, tokenStore, scopes, staticClientInfo) {
17
+ this.account = account;
18
+ this.tokenStore = tokenStore;
19
+ this.scopes = scopes;
20
+ this.staticClientInfo = staticClientInfo;
21
+ this.client = this.createClient();
22
+ this.queue = new PQueue({ concurrency: 4 });
23
+ }
24
+ createClient() {
25
+ return new Client({ name: "mcp-jira", version: "0.1.0" });
26
+ }
27
+ async loadTokens() {
28
+ const stored = await this.tokenStore.get(this.account.alias);
29
+ if (!stored) {
30
+ throw new Error(`No tokens found for account ${this.account.alias}. Run login first.`);
31
+ }
32
+ this.tokens = stored;
33
+ }
34
+ tokenNeedsRefresh() {
35
+ if (!this.tokens) {
36
+ return true;
37
+ }
38
+ const now = Date.now();
39
+ return this.tokens.expiresAt < now + 5 * 60 * 1000;
40
+ }
41
+ async ensureValidTokens() {
42
+ if (!this.tokens) {
43
+ await this.loadTokens();
44
+ }
45
+ if (this.tokenNeedsRefresh()) {
46
+ if (!this.tokens?.refreshToken) {
47
+ throw new Error(`Tokens for ${this.account.alias} have expired and no refresh token is available.`);
48
+ }
49
+ const refreshed = await refreshTokensIfNeeded({
50
+ alias: this.account.alias,
51
+ tokenStore: this.tokenStore,
52
+ scopes: this.scopes,
53
+ staticClientInfo: this.staticClientInfo,
54
+ });
55
+ this.tokens = refreshed;
56
+ }
57
+ }
58
+ async connectStreamableHttp() {
59
+ if (!this.tokens) {
60
+ throw new Error("Missing tokens");
61
+ }
62
+ const transport = new StreamableHTTPClientTransport(new URL(MCP_SERVER_URL), {
63
+ requestInit: {
64
+ headers: {
65
+ authorization: `Bearer ${this.tokens.accessToken}`,
66
+ },
67
+ },
68
+ });
69
+ await this.client.connect(transport);
70
+ this.connected = true;
71
+ }
72
+ async connectSse() {
73
+ if (!this.tokens) {
74
+ throw new Error("Missing tokens");
75
+ }
76
+ const transport = new SSEClientTransport(new URL(MCP_SSE_URL), {
77
+ requestInit: {
78
+ headers: {
79
+ authorization: `Bearer ${this.tokens.accessToken}`,
80
+ },
81
+ },
82
+ eventSourceInit: {
83
+ headers: {
84
+ authorization: `Bearer ${this.tokens.accessToken}`,
85
+ },
86
+ },
87
+ });
88
+ await this.client.connect(transport);
89
+ this.connected = true;
90
+ }
91
+ async connect() {
92
+ await this.ensureValidTokens();
93
+ if (this.connected) {
94
+ return;
95
+ }
96
+ try {
97
+ await this.connectStreamableHttp();
98
+ debug(`[${this.account.alias}] Connected via Streamable HTTP`);
99
+ }
100
+ catch (err) {
101
+ warn(`[${this.account.alias}] Streamable HTTP failed, falling back to SSE: ${String(err)}`);
102
+ await this.connectSse();
103
+ }
104
+ }
105
+ async refreshAndReconnect() {
106
+ await this.ensureValidTokens();
107
+ await this.client.close();
108
+ this.client = this.createClient();
109
+ this.connected = false;
110
+ await this.connect();
111
+ }
112
+ shouldRefreshOnError(err) {
113
+ if (!err || typeof err !== "object") {
114
+ return false;
115
+ }
116
+ const code = err.code;
117
+ if (code === 401 || code === 403) {
118
+ return true;
119
+ }
120
+ const message = String(err);
121
+ return (message.toLowerCase().includes("unauthorized") ||
122
+ message.toLowerCase().includes("forbidden"));
123
+ }
124
+ async listTools() {
125
+ if (!this.connected) {
126
+ await this.connect();
127
+ }
128
+ const tools = [];
129
+ let cursor;
130
+ do {
131
+ const result = await this.client.listTools({ cursor });
132
+ tools.push(...result.tools);
133
+ cursor = result.nextCursor;
134
+ } while (cursor);
135
+ return tools;
136
+ }
137
+ async callTool(name, args) {
138
+ if (!this.connected) {
139
+ await this.connect();
140
+ }
141
+ return this.queue.add(async () => {
142
+ try {
143
+ return await this.client.callTool({
144
+ name,
145
+ arguments: args,
146
+ });
147
+ }
148
+ catch (err) {
149
+ if (this.shouldRefreshOnError(err)) {
150
+ await this.refreshAndReconnect();
151
+ return this.client.callTool({
152
+ name,
153
+ arguments: args,
154
+ });
155
+ }
156
+ throw err;
157
+ }
158
+ });
159
+ }
160
+ async close() {
161
+ await this.client.close();
162
+ }
163
+ }
@@ -0,0 +1,266 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { info, warn } from "../utils/log.js";
5
+ function toolError(message) {
6
+ return {
7
+ content: [{ type: "text", text: message }],
8
+ isError: true,
9
+ };
10
+ }
11
+ function isRecord(value) {
12
+ return Boolean(value) && typeof value === "object";
13
+ }
14
+ function isToolResult(value) {
15
+ return isRecord(value) && Array.isArray(value.content);
16
+ }
17
+ function sanitizeInputSchema(inputSchema) {
18
+ if (!inputSchema || typeof inputSchema !== "object") {
19
+ return inputSchema;
20
+ }
21
+ const baseSchema = { ...inputSchema };
22
+ if (baseSchema.properties && typeof baseSchema.properties === "object") {
23
+ const { cloudId: _cloudId, ...rest } = baseSchema.properties;
24
+ baseSchema.properties = rest;
25
+ }
26
+ if (Array.isArray(baseSchema.required)) {
27
+ baseSchema.required = baseSchema.required.filter((item) => item !== "cloudId");
28
+ }
29
+ return baseSchema;
30
+ }
31
+ function getObjectShape(schema) {
32
+ if (!schema || typeof schema !== "object") {
33
+ return null;
34
+ }
35
+ const maybeShape = schema.shape;
36
+ if (maybeShape && typeof maybeShape === "object") {
37
+ return maybeShape;
38
+ }
39
+ const maybeDefShape = schema._def?.shape;
40
+ if (maybeDefShape && typeof maybeDefShape === "object") {
41
+ return maybeDefShape;
42
+ }
43
+ if (typeof maybeDefShape === "function") {
44
+ try {
45
+ const evaluated = maybeDefShape();
46
+ if (evaluated && typeof evaluated === "object") {
47
+ return evaluated;
48
+ }
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+ function buildToolSchema(inputSchema) {
57
+ const accountField = z
58
+ .string()
59
+ .describe("Account alias to route this call to.");
60
+ const accountOnly = z.object({ account: accountField }).loose();
61
+ const fromJSONSchema = z.fromJSONSchema;
62
+ if (!fromJSONSchema) {
63
+ return accountOnly;
64
+ }
65
+ try {
66
+ const remoteSchema = fromJSONSchema(sanitizeInputSchema(inputSchema) ?? { type: "object", properties: {} });
67
+ const shape = getObjectShape(remoteSchema);
68
+ if (!shape) {
69
+ return accountOnly;
70
+ }
71
+ const mergedShape = {
72
+ ...shape,
73
+ account: accountField,
74
+ };
75
+ return z.object(mergedShape).loose();
76
+ }
77
+ catch {
78
+ return accountOnly;
79
+ }
80
+ }
81
+ function normalizeToolResult(result) {
82
+ if (isToolResult(result)) {
83
+ return result;
84
+ }
85
+ if (isRecord(result) && "toolResult" in result) {
86
+ const structuredContent = isRecord(result.toolResult)
87
+ ? result.toolResult
88
+ : undefined;
89
+ return {
90
+ content: [
91
+ {
92
+ type: "text",
93
+ text: JSON.stringify(result.toolResult),
94
+ },
95
+ ],
96
+ ...(structuredContent ? { structuredContent } : {}),
97
+ };
98
+ }
99
+ return {
100
+ content: [
101
+ {
102
+ type: "text",
103
+ text: JSON.stringify(result),
104
+ },
105
+ ],
106
+ };
107
+ }
108
+ async function buildAccountStatusMap(sessionManager, accounts) {
109
+ const statusMap = new Map();
110
+ const getAccountAuthStatus = sessionManager.getAccountAuthStatus;
111
+ if (!getAccountAuthStatus) {
112
+ return statusMap;
113
+ }
114
+ await Promise.all(accounts.map(async (account) => {
115
+ try {
116
+ const status = await getAccountAuthStatus(account.alias, {
117
+ allowPrompt: false,
118
+ });
119
+ statusMap.set(account.alias, status);
120
+ }
121
+ catch (err) {
122
+ statusMap.set(account.alias, {
123
+ status: "unknown",
124
+ reason: String(err),
125
+ });
126
+ }
127
+ }));
128
+ return statusMap;
129
+ }
130
+ function buildAccountSummaryLine(account, status) {
131
+ const authLabel = status ? `auth: ${status.status}` : "auth: unknown";
132
+ return `${account.alias}: ${account.site} (cloudId: ${account.cloudId}, ${authLabel})`;
133
+ }
134
+ async function collectToolsForAccount(sessionManager, account) {
135
+ const session = sessionManager.getSession(account.alias);
136
+ if (!session) {
137
+ return null;
138
+ }
139
+ if (sessionManager.getAccountAuthStatus) {
140
+ const status = await sessionManager.getAccountAuthStatus(account.alias, {
141
+ allowPrompt: false,
142
+ });
143
+ if (status.status !== "ok") {
144
+ warn(`[${account.alias}] Skipping tool discovery (auth ${status.status}).`);
145
+ return null;
146
+ }
147
+ }
148
+ return session.listTools();
149
+ }
150
+ async function loadRemoteTools(sessionManager, accounts) {
151
+ const toolMap = new Map();
152
+ const toolErrors = [];
153
+ for (const account of accounts) {
154
+ try {
155
+ const tools = await collectToolsForAccount(sessionManager, account);
156
+ if (!tools) {
157
+ continue;
158
+ }
159
+ for (const tool of tools) {
160
+ if (!toolMap.has(tool.name)) {
161
+ toolMap.set(tool.name, {
162
+ description: tool.description,
163
+ inputSchema: tool.inputSchema,
164
+ });
165
+ }
166
+ }
167
+ }
168
+ catch (err) {
169
+ const message = `[${account.alias}] Failed to fetch tools during startup: ${String(err)}`;
170
+ toolErrors.push(message);
171
+ warn(message);
172
+ }
173
+ }
174
+ return { toolMap, toolErrors };
175
+ }
176
+ function buildToolDescription(tool) {
177
+ const required = tool.inputSchema?.required ?? [];
178
+ const requiredHintList = required.filter((item) => item !== "cloudId");
179
+ const requiredHint = requiredHintList.length > 0
180
+ ? `Required parameters: ${requiredHintList.join(", ")}.`
181
+ : "";
182
+ return ((tool.description ? `${tool.description}\n\n` : "") +
183
+ "Required parameter: account (account alias).\n" +
184
+ requiredHint);
185
+ }
186
+ function splitAccountArgs(args) {
187
+ const { account, ...rest } = args;
188
+ return { account, rest };
189
+ }
190
+ function applyCloudIdFallback(sessionManager, account, tool, args) {
191
+ const requiredParams = tool.inputSchema?.required ?? [];
192
+ if (!requiredParams.includes("cloudId") || args.cloudId !== undefined) {
193
+ return;
194
+ }
195
+ const accountInfo = sessionManager
196
+ .listAccounts()
197
+ .find((item) => item.alias === account);
198
+ if (accountInfo?.cloudId && accountInfo.cloudId !== "unknown") {
199
+ args.cloudId = accountInfo.cloudId;
200
+ }
201
+ }
202
+ function createToolHandler(sessionManager, toolName, tool) {
203
+ return async (args) => {
204
+ const { account, rest } = splitAccountArgs(args);
205
+ if (!account) {
206
+ return toolError("Missing required parameter: account");
207
+ }
208
+ const session = sessionManager.getSession(account);
209
+ if (!session) {
210
+ return toolError(`Unknown account alias: ${account}`);
211
+ }
212
+ applyCloudIdFallback(sessionManager, account, tool, rest);
213
+ try {
214
+ const result = await session.callTool(toolName, rest);
215
+ return normalizeToolResult(result);
216
+ }
217
+ catch (err) {
218
+ return toolError(`Failed to call ${toolName} for ${account}: ${String(err)}`);
219
+ }
220
+ };
221
+ }
222
+ export async function startLocalServer(sessionManager, version) {
223
+ const server = new McpServer({ name: "mcp-jira", version }, {
224
+ instructions: "All Jira tools require an 'account' parameter indicating the account alias. " +
225
+ "Use listJiraAccounts to discover available aliases and cloud IDs. " +
226
+ "If a tool requires 'cloudId' and it is missing, the server will fill it from the selected account when possible. " +
227
+ "For JQL search use the 'jql' parameter.",
228
+ });
229
+ server.registerTool("listJiraAccounts", {
230
+ description: "List configured Jira account aliases and site metadata.",
231
+ }, async () => {
232
+ const accounts = sessionManager.listAccounts();
233
+ const statusMap = await buildAccountStatusMap(sessionManager, accounts);
234
+ const summary = accounts
235
+ .map((account) => buildAccountSummaryLine(account, statusMap.get(account.alias)))
236
+ .join("\n");
237
+ return {
238
+ content: [
239
+ {
240
+ type: "text",
241
+ text: summary || "No accounts are configured.",
242
+ },
243
+ ],
244
+ structuredContent: {
245
+ accounts: accounts.map((account) => ({
246
+ ...account,
247
+ auth: statusMap.get(account.alias) ?? { status: "unknown" },
248
+ })),
249
+ },
250
+ };
251
+ });
252
+ const sessions = sessionManager.listAccounts();
253
+ const { toolMap, toolErrors } = await loadRemoteTools(sessionManager, sessions);
254
+ if (toolMap.size === 0 && sessions.length > 0) {
255
+ warn(`No remote tools could be loaded. ${toolErrors.join(" ")}`.trim());
256
+ }
257
+ for (const [toolName, tool] of toolMap) {
258
+ server.registerTool(toolName, {
259
+ description: buildToolDescription(tool),
260
+ inputSchema: buildToolSchema(tool.inputSchema),
261
+ }, createToolHandler(sessionManager, toolName, tool));
262
+ }
263
+ const transport = new StdioServerTransport();
264
+ await server.connect(transport);
265
+ info("MCP server is running (stdio).");
266
+ }
@@ -0,0 +1,62 @@
1
+ import { loadConfig } from "../config/store.js";
2
+ import { getAuthStatusForAlias, } from "../security/token-store.js";
3
+ import { warn } from "../utils/log.js";
4
+ import { RemoteSession } from "./remote-session.js";
5
+ export class SessionManager {
6
+ tokenStore;
7
+ scopes;
8
+ staticClientInfo;
9
+ tokenStoreKind;
10
+ sessions = new Map();
11
+ accounts = new Map();
12
+ constructor(tokenStore, scopes, staticClientInfo, tokenStoreKind) {
13
+ this.tokenStore = tokenStore;
14
+ this.scopes = scopes;
15
+ this.staticClientInfo = staticClientInfo;
16
+ this.tokenStoreKind = tokenStoreKind;
17
+ }
18
+ async loadAll() {
19
+ const config = await loadConfig();
20
+ this.accounts = new Map(Object.values(config.accounts).map((account) => [account.alias, account]));
21
+ for (const account of this.accounts.values()) {
22
+ const session = new RemoteSession(account, this.tokenStore, this.scopes, this.staticClientInfo);
23
+ this.sessions.set(account.alias, session);
24
+ }
25
+ }
26
+ listAccounts() {
27
+ return Array.from(this.accounts.values());
28
+ }
29
+ getSession(alias) {
30
+ return this.sessions.get(alias) ?? null;
31
+ }
32
+ getAccountAuthStatus(alias, options) {
33
+ return getAuthStatusForAlias({
34
+ alias,
35
+ tokenStore: this.tokenStore,
36
+ storeKind: this.tokenStoreKind,
37
+ allowPrompt: options?.allowPrompt ?? false,
38
+ });
39
+ }
40
+ async connectAll() {
41
+ const sessions = Array.from(this.sessions.values());
42
+ const results = await Promise.allSettled(sessions.map(async (session) => {
43
+ const status = await this.getAccountAuthStatus(session.account.alias, {
44
+ allowPrompt: false,
45
+ });
46
+ if (status.status !== "ok") {
47
+ warn(`[${session.account.alias}] Auth status ${status.status}. ${status.reason ?? "Run login."}`);
48
+ return;
49
+ }
50
+ await session.connect();
51
+ }));
52
+ results.forEach((result, index) => {
53
+ if (result.status === "rejected") {
54
+ const session = sessions[index];
55
+ warn(`[${session.account.alias}] Failed to connect: ${String(result.reason)}`);
56
+ }
57
+ });
58
+ }
59
+ async closeAll() {
60
+ await Promise.all(Array.from(this.sessions.values()).map((session) => session.close()));
61
+ }
62
+ }