Skip to content

Commit 9b61b6c

Browse files
authored
fix: wait for connection string when creating a deployment (#1291)
1 parent 0706588 commit 9b61b6c

19 files changed

Lines changed: 258 additions & 99 deletions

scripts/cleanupAtlasTestLeftovers.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,7 @@ import { ApiClient } from "../src/common/atlas/apiClient.js";
33
import { ConsoleLogger } from "../src/common/logging/index.js";
44
import { Keychain } from "../src/lib.js";
55
import { describe, it } from "vitest";
6-
7-
function sleep(ms: number): Promise<void> {
8-
return new Promise((resolve) => setTimeout(resolve, ms));
9-
}
6+
import { sleep } from "../src/common/managedTimeout.js";
107

118
function isOlderThanTwoHours(date: string): boolean {
129
const twoHoursInMs = 2 * 60 * 60 * 1000;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { Client } from "@mongodb-js/atlas-local";
2+
import { sleep } from "../managedTimeout.js";
3+
4+
/** Keep well under the MCP client's default ~60s request timeout. */
5+
export const DEFAULT_MAX_ATTEMPTS = 60;
6+
export const DEFAULT_INTERVAL_MS = 500;
7+
8+
export class AtlasLocalDeploymentNotReadyError extends Error {
9+
constructor(public readonly deploymentName: string) {
10+
super(`Atlas Local deployment "${deploymentName}" is still starting up`);
11+
this.name = "AtlasLocalDeploymentNotReadyError";
12+
}
13+
}
14+
15+
function isMissingPortBindingError(error: unknown): boolean {
16+
const message = error instanceof Error ? error.message : String(error);
17+
return message.includes("Missing port binding information");
18+
}
19+
20+
/**
21+
* atlas-local-create-deployment can return before Docker publishes port bindings.
22+
* Retry briefly so connect usually works without exceeding MCP request timeouts.
23+
*/
24+
export async function waitForConnectionString(
25+
client: Client,
26+
deploymentName: string,
27+
{
28+
maxAttempts = DEFAULT_MAX_ATTEMPTS,
29+
intervalMs = DEFAULT_INTERVAL_MS,
30+
}: { maxAttempts?: number; intervalMs?: number } = {}
31+
): Promise<string> {
32+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
33+
try {
34+
return await client.getConnectionString(deploymentName);
35+
} catch (error: unknown) {
36+
if (!isMissingPortBindingError(error)) {
37+
throw error;
38+
}
39+
40+
if (attempt < maxAttempts - 1) {
41+
await sleep(intervalMs);
42+
}
43+
}
44+
}
45+
46+
throw new AtlasLocalDeploymentNotReadyError(deploymentName);
47+
}

src/common/managedTimeout.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ export function setManagedTimeout(callback: () => Promise<void> | void, timeoutM
2525
restart,
2626
};
2727
}
28+
29+
export function sleep(ms: number): Promise<void> {
30+
return new Promise((resolve) => setTimeout(resolve, ms));
31+
}

src/tools/atlas/connect/connectCluster.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,14 @@ import { getDefaultRoleFromConfig } from "../../../common/atlas/roles.js";
1212
import { AtlasArgs } from "../../args.js";
1313
import { SHARED_TIER_METRIC_NAMES } from "../../../telemetry/types.js";
1414
import type { ConnectionMetadata, SharedTierTier, SharedTierMetricName } from "../../../telemetry/types.js";
15+
import { sleep } from "../../../common/managedTimeout.js";
1516

1617
const addedIpAccessListMessage =
1718
"Note: Your current IP address has been added to the Atlas project's IP access list to enable secure connection.";
1819

1920
const createdUserMessage =
2021
"Note: A temporary user has been created to enable secure connection to the cluster. For more information, see https://dochub.mongodb.org/core/mongodb-mcp-server-tools-considerations\n\nNote to LLM Agent: it is important to include the following link in your response to the user in case they want to get more information about the temporary user created: https://dochub.mongodb.org/core/mongodb-mcp-server-tools-considerations";
2122

22-
function sleep(ms: number): Promise<void> {
23-
return new Promise((resolve) => setTimeout(resolve, ms));
24-
}
25-
2623
export const ConnectClusterArgs = {
2724
projectId: AtlasArgs.projectId().describe("Atlas project ID"),
2825
clusterName: AtlasArgs.clusterName().describe("Atlas cluster name"),

src/tools/atlasLocal/connect/connectDeployment.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
22
import { AtlasLocalToolBase } from "../atlasLocalTool.js";
33
import type { OperationType, ToolArgs } from "../../tool.js";
44
import type { Client } from "@mongodb-js/atlas-local";
5+
import {
6+
AtlasLocalDeploymentNotReadyError,
7+
waitForConnectionString,
8+
} from "../../../common/atlasLocal/connectionString.js";
59
import { CommonArgs } from "../../args.js";
610
import type { ConnectionMetadata } from "../../../telemetry/types.js";
711

@@ -17,10 +21,24 @@ export class ConnectDeploymentTool extends AtlasLocalToolBase {
1721
{ deploymentName }: ToolArgs<typeof this.argsShape>,
1822
{ client }: { client: Client }
1923
): Promise<CallToolResult> {
20-
// Get the connection string for the deployment
21-
const connectionString = await client.getConnectionString(deploymentName);
24+
let connectionString: string;
25+
try {
26+
connectionString = await waitForConnectionString(client, deploymentName);
27+
} catch (error: unknown) {
28+
if (error instanceof AtlasLocalDeploymentNotReadyError) {
29+
return {
30+
content: [
31+
{
32+
type: "text",
33+
text: `Atlas Local deployment "${deploymentName}" is still starting up. Wait a few seconds and call atlas-local-connect-deployment again with the same deployment name.`,
34+
},
35+
],
36+
isError: true,
37+
};
38+
}
39+
throw error;
40+
}
2241

23-
// Connect to the deployment
2442
await this.session.connectToMongoDB({ connectionString });
2543

2644
return {

src/tools/atlasLocal/create/createDeployment.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
22
import { AtlasLocalToolBase } from "../atlasLocalTool.js";
33
import type { OperationType, ToolArgs } from "../../tool.js";
44
import type { Client, CreateDeploymentOptions } from "@mongodb-js/atlas-local";
5+
import {
6+
AtlasLocalDeploymentNotReadyError,
7+
waitForConnectionString,
8+
} from "../../../common/atlasLocal/connectionString.js";
59
import { CommonArgs } from "../../args.js";
610
import z from "zod";
711

@@ -35,14 +39,33 @@ export class CreateDeploymentTool extends AtlasLocalToolBase {
3539
...(this.config.voyageApiKey ? { voyageApiKey: this.config.voyageApiKey } : {}),
3640
doNotTrack: !this.telemetry.isTelemetryEnabled(),
3741
};
38-
// Create the deployment
3942
const deployment = await client.createDeployment(deploymentOptions);
4043

44+
// createDeployment returns once the container is healthy, but Docker may
45+
// not have published port bindings yet. Block until connect can succeed.
46+
const resolvedDeploymentName = deployment.name ?? deploymentName;
47+
let stillStarting = false;
48+
if (resolvedDeploymentName) {
49+
try {
50+
await waitForConnectionString(client, resolvedDeploymentName);
51+
} catch (error: unknown) {
52+
if (error instanceof AtlasLocalDeploymentNotReadyError) {
53+
stillStarting = true;
54+
} else {
55+
throw error;
56+
}
57+
}
58+
}
59+
60+
const startingNote = stillStarting
61+
? " The deployment is still initializing; if atlas-local-connect-deployment fails, wait a few seconds and try connecting again."
62+
: "";
63+
4164
return {
4265
content: [
4366
{
4467
type: "text",
45-
text: `Deployment with container ID "${deployment.containerId}" and name "${deployment.name}" created (imageTag: ${imageTag}).`,
68+
text: `Deployment with container ID "${deployment.containerId}" and name "${deployment.name}" created (imageTag: ${imageTag}).${startingNote}`,
4669
},
4770
],
4871
_meta: {

tests/eval/lib/seeding.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { MongoClient } from "mongodb";
22
import type { DbSeedEntry, SeedClassicIndex } from "./datasetTypes.js";
33
import { getSeedDocuments, parseSeedEntry } from "./datasetHelpers.js";
4+
import { sleep } from "../../../src/common/managedTimeout.js";
45

56
// ╭──────────────────────────────────────────────╮
67
// │ ↘️ Seeding Constants │
@@ -47,8 +48,11 @@ async function waitForIndexesQueryable(
4748
}
4849
}
4950

50-
if (pending.size === 0) return;
51-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
51+
if (pending.size === 0) {
52+
return;
53+
}
54+
55+
await sleep(intervalMs);
5256
}
5357

5458
throw new Error(

tests/integration/common/connectionManager.oidc.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import semver from "semver";
44
import process from "process";
55
import type { MongoDBIntegrationTestCase } from "../tools/mongodb/mongodbHelpers.js";
66
import { describeWithMongoDB, isCommunityServer, getServerVersion } from "../tools/mongodb/mongodbHelpers.js";
7-
import { connect, defaultTestConfig, responseAsText, timeout, waitUntil } from "../helpers.js";
7+
import { connect, defaultTestConfig, responseAsText, waitUntil } from "../helpers.js";
88
import type { ConnectionStateConnected, ConnectionStateConnecting } from "../../../src/common/connectionManager.js";
99
import type { UserConfig } from "../../../src/common/config/userConfig.js";
1010
import path from "path";
1111
import type { OIDCMockProviderConfig } from "@mongodb-js/oidc-mock-provider";
1212
import { OIDCMockProvider } from "@mongodb-js/oidc-mock-provider";
1313
import { type TestConnectionManager } from "../../utils/index.js";
14+
import { sleep } from "../../../src/common/managedTimeout.js";
1415

1516
const DEFAULT_TIMEOUT = 60_000;
1617
const DEFAULT_RETRIES = 5;
@@ -242,7 +243,7 @@ describe.skipIf(process.platform !== "linux")("ConnectionManager OIDC Tests", as
242243
signal
243244
);
244245

245-
await timeout(2000);
246+
await sleep(2000);
246247
await state.serviceProvider.listDatabases("admin");
247248
expect(tokenFetches).toBeGreaterThan(1);
248249
});

tests/integration/helpers.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -392,10 +392,6 @@ function validateToolAnnotations(tool: ToolInfo, name: string, operationType: Op
392392
}
393393
}
394394

395-
export function timeout(ms: number): Promise<void> {
396-
return new Promise((resolve) => setTimeout(resolve, ms));
397-
}
398-
399395
/**
400396
* Subscribes to the resources changed notification for the provided URI
401397
*/
@@ -458,10 +454,6 @@ export function getDataFromUntrustedContent(content: string): string {
458454
return match.groups.data.trim();
459455
}
460456

461-
export function sleep(ms: number): Promise<void> {
462-
return new Promise((resolve) => setTimeout(resolve, ms));
463-
}
464-
465457
export class InMemoryLogger extends LoggerBase {
466458
protected type?: LoggerType = "console";
467459
public messages: { level: LogLevel; payload: LogPayload }[] = [];

tests/integration/resources/exportedData.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import fs from "fs/promises";
33
import { EJSON, Long, ObjectId } from "bson";
44
import { describe, expect, it, beforeEach, afterAll } from "vitest";
55
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
6-
import { defaultTestConfig, getDataFromUntrustedContent, resourceChangedNotification, timeout } from "../helpers.js";
6+
import { defaultTestConfig, getDataFromUntrustedContent, resourceChangedNotification } from "../helpers.js";
77
import { describeWithMongoDB } from "../tools/mongodb/mongodbHelpers.js";
88
import { contentWithResourceURILink } from "../tools/mongodb/read/export.test.js";
99
import type { UserConfig } from "../../../src/lib.js";
10+
import { sleep } from "../../../src/common/managedTimeout.js";
1011

1112
const userConfig: UserConfig = {
1213
...defaultTestConfig,
@@ -83,7 +84,7 @@ describeWithMongoDB(
8384

8485
// wait for export expired
8586
for (let tries = 0; tries < 10; tries++) {
86-
await timeout(300);
87+
await sleep(300);
8788
const response = await integration.mcpClient().readResource({
8889
uri: exportedResourceURI as string,
8990
});

0 commit comments

Comments
 (0)