Skip to content

Commit 5abb986

Browse files
committed
Merge branch 'main' into fmenezes/use_openapi_fetch
2 parents e0e4bf7 + 7156dd8 commit 5abb986

File tree

13 files changed

+144
-72
lines changed

13 files changed

+144
-72
lines changed

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ This project implements a Model Context Protocol (MCP) server for MongoDB and Mo
1010

1111
### Prerequisites
1212

13-
- Node.js (v23 or later)
13+
- Node.js (v20 or later)
1414
- npm
1515

1616
### Getting Started

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ A Model Context Protocol server for interacting with MongoDB Atlas. This project
1919

2020
### Prerequisites
2121

22-
- Node.js (v23 or later)
22+
- Node.js (v20 or later)
2323
- MongoDB Atlas account
2424

2525
### Installation

package-lock.json

Lines changed: 10 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"@redocly/cli": "^1.34.2",
3939
"@types/node": "^22.14.0",
4040
"@types/simple-oauth2": "^5.0.7",
41+
"@types/yargs-parser": "^21.0.3",
4142
"eslint": "^9.24.0",
4243
"eslint-config-prettier": "^10.1.1",
4344
"globals": "^16.0.0",
@@ -59,9 +60,10 @@
5960
"mongodb-redact": "^1.1.6",
6061
"mongodb-schema": "^12.6.2",
6162
"openapi-fetch": "^0.13.5",
63+
"yargs-parser": "^21.1.1",
6264
"zod": "^3.24.2"
6365
},
6466
"engines": {
65-
"node": ">=23.0.0"
67+
"node": ">=20.0.0"
6668
}
6769
}

src/common/atlas/apiClient.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export class ApiClient {
4747
private token?: OAuthToken;
4848
private saveToken?: saveTokenFunction;
4949
private client = createClient<paths>({
50-
baseUrl: config.apiBaseURL,
50+
baseUrl: config.apiBaseUrl,
5151
headers: {
5252
"User-Agent": config.userAgent,
5353
Accept: `application/vnd.atlas.${config.atlasApiVersion}+json`,
@@ -93,7 +93,7 @@ export class ApiClient {
9393
async authenticate(): Promise<OauthDeviceCode> {
9494
const endpoint = "api/private/unauth/account/device/authorize";
9595

96-
const authUrl = new URL(endpoint, config.apiBaseURL);
96+
const authUrl = new URL(endpoint, config.apiBaseUrl);
9797

9898
const response = await fetch(authUrl, {
9999
method: "POST",
@@ -102,7 +102,7 @@ export class ApiClient {
102102
Accept: "application/json",
103103
},
104104
body: new URLSearchParams({
105-
client_id: config.clientID,
105+
client_id: config.clientId,
106106
scope: "openid profile offline_access",
107107
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
108108
}).toString(),
@@ -117,14 +117,14 @@ export class ApiClient {
117117

118118
async retrieveToken(device_code: string): Promise<OAuthToken> {
119119
const endpoint = "api/private/unauth/account/device/token";
120-
const url = new URL(endpoint, config.apiBaseURL);
120+
const url = new URL(endpoint, config.apiBaseUrl);
121121
const response = await fetch(url, {
122122
method: "POST",
123123
headers: {
124124
"Content-Type": "application/x-www-form-urlencoded",
125125
},
126126
body: new URLSearchParams({
127-
client_id: config.clientID,
127+
client_id: config.clientId,
128128
device_code: device_code,
129129
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
130130
}).toString(),
@@ -153,15 +153,15 @@ export class ApiClient {
153153

154154
async refreshToken(token?: OAuthToken): Promise<OAuthToken | null> {
155155
const endpoint = "api/private/unauth/account/device/token";
156-
const url = new URL(endpoint, config.apiBaseURL);
156+
const url = new URL(endpoint, config.apiBaseUrl);
157157
const response = await fetch(url, {
158158
method: "POST",
159159
headers: {
160160
"Content-Type": "application/x-www-form-urlencoded",
161161
Accept: "application/json",
162162
},
163163
body: new URLSearchParams({
164-
client_id: config.clientID,
164+
client_id: config.clientId,
165165
refresh_token: (token || this.token)?.refresh_token || "",
166166
grant_type: "refresh_token",
167167
scope: "openid profile offline_access",
@@ -187,7 +187,7 @@ export class ApiClient {
187187

188188
async revokeToken(token?: OAuthToken): Promise<void> {
189189
const endpoint = "api/private/unauth/account/device/token";
190-
const url = new URL(endpoint, config.apiBaseURL);
190+
const url = new URL(endpoint, config.apiBaseUrl);
191191
const response = await fetch(url, {
192192
method: "POST",
193193
headers: {
@@ -196,7 +196,7 @@ export class ApiClient {
196196
"User-Agent": config.userAgent,
197197
},
198198
body: new URLSearchParams({
199-
client_id: config.clientID,
199+
client_id: config.clientId,
200200
token: (token || this.token)?.access_token || "",
201201
token_type_hint: "refresh_token",
202202
}).toString(),

src/common/atlas/auth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ApiClient } from "./apiClient";
2-
import { State } from "../../state";
1+
import { ApiClient } from "./apiClient.js";
2+
import { State } from "../../state.js";
33

44
export async function ensureAuthenticated(state: State, apiClient: ApiClient): Promise<void> {
55
if (!(await isAuthenticated(state, apiClient))) {

src/config.ts

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,97 @@
11
import path from "path";
2-
import fs from "fs";
3-
import { fileURLToPath } from "url";
42
import os from "os";
3+
import argv from "yargs-parser";
4+
5+
import packageJson from "../package.json" with { type: "json" };
6+
import fs from "fs";
7+
const { localDataPath, configPath } = getLocalDataPath();
58

6-
const __filename = fileURLToPath(import.meta.url);
7-
const __dirname = path.dirname(__filename);
9+
// If we decide to support non-string config options, we'll need to extend the mechanism for parsing
10+
// env variables.
11+
interface UserConfig extends Record<string, string> {
12+
apiBaseUrl: string;
13+
clientId: string;
14+
stateFile: string;
15+
}
816

9-
const packageMetadata = fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8");
10-
const packageJson = JSON.parse(packageMetadata);
17+
const defaults: UserConfig = {
18+
apiBaseUrl: "https://cloud.mongodb.com/",
19+
clientId: "0oabtxactgS3gHIR0297",
20+
stateFile: path.join(localDataPath, "state.json"),
21+
};
1122

12-
export const config = {
23+
const mergedUserConfig = {
24+
...defaults,
25+
...getFileConfig(),
26+
...getEnvConfig(),
27+
...getCliConfig(),
28+
};
29+
30+
const config = {
31+
...mergedUserConfig,
1332
atlasApiVersion: `2025-03-12`,
1433
version: packageJson.version,
15-
apiBaseURL: process.env.API_BASE_URL || "https://cloud.mongodb.com/",
16-
clientID: process.env.CLIENT_ID || "0oabtxactgS3gHIR0297",
17-
stateFile: process.env.STATE_FILE || path.resolve("./state.json"),
1834
userAgent: `AtlasMCP/${packageJson.version} (${process.platform}; ${process.arch}; ${process.env.HOSTNAME || "unknown"})`,
19-
localDataPath: getLocalDataPath(),
35+
localDataPath,
2036
};
2137

2238
export default config;
2339

24-
function getLocalDataPath() {
40+
function getLocalDataPath(): { localDataPath: string; configPath: string } {
41+
let localDataPath: string | undefined;
42+
let configPath: string | undefined;
43+
2544
if (process.platform === "win32") {
2645
const appData = process.env.APPDATA;
2746
const localAppData = process.env.LOCALAPPDATA ?? process.env.APPDATA;
2847
if (localAppData && appData) {
29-
return path.join(localAppData, "mongodb", "mongodb-mcp");
48+
localDataPath = path.join(localAppData, "mongodb", "mongodb-mcp");
49+
configPath = path.join(localDataPath, "mongodb-mcp.conf");
3050
}
3151
}
3252

33-
return path.join(os.homedir(), ".mongodb", "mongodb-mcp");
53+
localDataPath ??= path.join(os.homedir(), ".mongodb", "mongodb-mcp");
54+
configPath ??= "/etc/mongodb-mcp.conf";
55+
56+
fs.mkdirSync(localDataPath, { recursive: true });
57+
58+
return {
59+
localDataPath,
60+
configPath,
61+
};
62+
}
63+
64+
// Gets the config supplied by the user as environment variables. The variable names
65+
// are prefixed with `MDB_MCP_` and the keys match the UserConfig keys, but are converted
66+
// to SNAKE_UPPER_CASE.
67+
function getEnvConfig(): Partial<UserConfig> {
68+
const camelCaseToSNAKE_UPPER_CASE = (str: string): string => {
69+
return str.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase();
70+
};
71+
72+
const result: Partial<UserConfig> = {};
73+
for (const key of Object.keys(defaults)) {
74+
const envVarName = `MDB_MCP_${camelCaseToSNAKE_UPPER_CASE(key)}`;
75+
if (process.env[envVarName]) {
76+
result[key] = process.env[envVarName];
77+
}
78+
}
79+
80+
return result;
81+
}
82+
83+
// Gets the config supplied by the user as a JSON file. The file is expected to be located in the local data path
84+
// and named `config.json`.
85+
function getFileConfig(): Partial<UserConfig> {
86+
try {
87+
const config = fs.readFileSync(configPath, "utf8");
88+
return JSON.parse(config);
89+
} catch {
90+
return {};
91+
}
92+
}
93+
94+
// Reads the cli args and parses them into a UserConfig object.
95+
function getCliConfig() {
96+
return argv(process.argv.slice(2)) as unknown as Partial<UserConfig>;
3497
}

src/server.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { State, saveState, loadState } from "./state.js";
44
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
55
import { registerAtlasTools } from "./tools/atlas/tools.js";
66
import { registerMongoDBTools } from "./tools/mongodb/index.js";
7-
import { config } from "./config.js";
7+
import config from "./config.js";
88
import logger, { initializeLogger } from "./logger.js";
99
import { mongoLogId } from "mongodb-log-writer";
1010

@@ -21,14 +21,14 @@ export class Server {
2121

2222
this.apiClient = new ApiClient({
2323
token: this.state?.auth.token,
24-
saveToken: (token) => {
24+
saveToken: async (token) => {
2525
if (!this.state) {
2626
throw new Error("State is not initialized");
2727
}
2828
this.state.auth.code = undefined;
2929
this.state.auth.token = token;
3030
this.state.auth.status = "issued";
31-
saveState(this.state);
31+
await saveState(this.state);
3232
},
3333
});
3434

src/state.ts

Lines changed: 18 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import fs from "fs";
1+
import fs from "fs/promises";
22
import config from "./config.js";
33
import { OauthDeviceCode, OAuthToken } from "./common/atlas/apiClient.js";
44

@@ -8,37 +8,26 @@ export interface State {
88
code?: OauthDeviceCode;
99
token?: OAuthToken;
1010
};
11+
connectionString?: string;
1112
}
1213

1314
export async function saveState(state: State): Promise<void> {
14-
return new Promise((resolve, reject) => {
15-
fs.writeFile(config.stateFile, JSON.stringify(state), function (err) {
16-
if (err) {
17-
return reject(err);
18-
}
19-
20-
return resolve();
21-
});
22-
});
15+
await fs.writeFile(config.stateFile, JSON.stringify(state), { encoding: "utf-8" });
2316
}
2417

25-
export async function loadState() {
26-
return new Promise<State>((resolve, reject) => {
27-
fs.readFile(config.stateFile, "utf-8", (err, data) => {
28-
if (err) {
29-
if (err.code === "ENOENT") {
30-
// File does not exist, return default state
31-
const defaultState: State = {
32-
auth: {
33-
status: "not_auth",
34-
},
35-
};
36-
return resolve(defaultState);
37-
} else {
38-
return reject(err);
39-
}
40-
}
41-
return resolve(JSON.parse(data) as State);
42-
});
43-
});
18+
export async function loadState(): Promise<State> {
19+
try {
20+
const data = await fs.readFile(config.stateFile, "utf-8");
21+
return JSON.parse(data) as State;
22+
} catch (err: unknown) {
23+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") {
24+
return {
25+
auth: {
26+
status: "not_auth",
27+
},
28+
};
29+
}
30+
31+
throw err;
32+
}
4433
}

src/tools/atlas/auth.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export class AuthTool extends AtlasToolBase {
1111
protected argsShape = {};
1212

1313
private async isAuthenticated(): Promise<boolean> {
14-
return isAuthenticated(this.state!, this.apiClient);
14+
return isAuthenticated(this.state, this.apiClient);
1515
}
1616

1717
async execute(): Promise<CallToolResult> {
@@ -25,11 +25,11 @@ export class AuthTool extends AtlasToolBase {
2525
try {
2626
const code = await this.apiClient.authenticate();
2727

28-
this.state!.auth.status = "requested";
29-
this.state!.auth.code = code;
30-
this.state!.auth.token = undefined;
28+
this.state.auth.status = "requested";
29+
this.state.auth.code = code;
30+
this.state.auth.token = undefined;
3131

32-
await saveState(this.state!);
32+
await saveState(this.state);
3333

3434
return {
3535
content: [

0 commit comments

Comments
 (0)