Skip to content

Commit a031897

Browse files
authored
feat: add support for supplying config options (#45)
1 parent c118770 commit a031897

File tree

8 files changed

+128
-56
lines changed

8 files changed

+128
-56
lines changed

package-lock.json

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
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",
@@ -58,6 +59,7 @@
5859
"mongodb-log-writer": "^2.4.1",
5960
"mongodb-redact": "^1.1.6",
6061
"mongodb-schema": "^12.6.2",
62+
"yargs-parser": "^21.1.1",
6163
"zod": "^3.24.2"
6264
},
6365
"engines": {

src/common/atlas/apiClient.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export class ApiClient {
9191
throw new Error("Not authenticated. Please run the auth tool first.");
9292
}
9393

94-
const url = new URL(`api/atlas/v2${endpoint}`, `${config.apiBaseURL}`);
94+
const url = new URL(`api/atlas/v2${endpoint}`, `${config.apiBaseUrl}`);
9595

9696
if (!this.checkTokenExpiry()) {
9797
await this.refreshToken();
@@ -119,7 +119,7 @@ export class ApiClient {
119119
async authenticate(): Promise<OauthDeviceCode> {
120120
const endpoint = "api/private/unauth/account/device/authorize";
121121

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

124124
const response = await fetch(authUrl, {
125125
method: "POST",
@@ -128,7 +128,7 @@ export class ApiClient {
128128
Accept: "application/json",
129129
},
130130
body: new URLSearchParams({
131-
client_id: config.clientID,
131+
client_id: config.clientId,
132132
scope: "openid profile offline_access",
133133
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
134134
}).toString(),
@@ -143,14 +143,14 @@ export class ApiClient {
143143

144144
async retrieveToken(device_code: string): Promise<OAuthToken> {
145145
const endpoint = "api/private/unauth/account/device/token";
146-
const url = new URL(endpoint, config.apiBaseURL);
146+
const url = new URL(endpoint, config.apiBaseUrl);
147147
const response = await fetch(url, {
148148
method: "POST",
149149
headers: {
150150
"Content-Type": "application/x-www-form-urlencoded",
151151
},
152152
body: new URLSearchParams({
153-
client_id: config.clientID,
153+
client_id: config.clientId,
154154
device_code: device_code,
155155
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
156156
}).toString(),
@@ -179,15 +179,15 @@ export class ApiClient {
179179

180180
async refreshToken(token?: OAuthToken): Promise<OAuthToken | null> {
181181
const endpoint = "api/private/unauth/account/device/token";
182-
const url = new URL(endpoint, config.apiBaseURL);
182+
const url = new URL(endpoint, config.apiBaseUrl);
183183
const response = await fetch(url, {
184184
method: "POST",
185185
headers: {
186186
"Content-Type": "application/x-www-form-urlencoded",
187187
Accept: "application/json",
188188
},
189189
body: new URLSearchParams({
190-
client_id: config.clientID,
190+
client_id: config.clientId,
191191
refresh_token: (token || this.token)?.refresh_token || "",
192192
grant_type: "refresh_token",
193193
scope: "openid profile offline_access",
@@ -213,7 +213,7 @@ export class ApiClient {
213213

214214
async revokeToken(token?: OAuthToken): Promise<void> {
215215
const endpoint = "api/private/unauth/account/device/token";
216-
const url = new URL(endpoint, config.apiBaseURL);
216+
const url = new URL(endpoint, config.apiBaseUrl);
217217
const response = await fetch(url, {
218218
method: "POST",
219219
headers: {
@@ -222,7 +222,7 @@ export class ApiClient {
222222
"User-Agent": config.userAgent,
223223
},
224224
body: new URLSearchParams({
225-
client_id: config.clientID,
225+
client_id: config.clientId,
226226
token: (token || this.token)?.access_token || "",
227227
token_type_hint: "refresh_token",
228228
}).toString(),

src/config.ts

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,97 @@
11
import path from "path";
22
import os from "os";
3+
import argv from "yargs-parser";
4+
35
import packageJson from "../package.json" with { type: "json" };
6+
import fs from "fs";
7+
const { localDataPath, configPath } = getLocalDataPath();
8+
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+
}
416

5-
export const config = {
17+
const defaults: UserConfig = {
18+
apiBaseUrl: "https://cloud.mongodb.com/",
19+
clientId: "0oabtxactgS3gHIR0297",
20+
stateFile: path.join(localDataPath, "state.json"),
21+
};
22+
23+
const mergedUserConfig = {
24+
...defaults,
25+
...getFileConfig(),
26+
...getEnvConfig(),
27+
...getCliConfig(),
28+
};
29+
30+
const config = {
31+
...mergedUserConfig,
632
atlasApiVersion: `2025-03-12`,
733
version: packageJson.version,
8-
apiBaseURL: process.env.API_BASE_URL || "https://cloud.mongodb.com/",
9-
clientID: process.env.CLIENT_ID || "0oabtxactgS3gHIR0297",
10-
stateFile: process.env.STATE_FILE || path.resolve("./state.json"),
1134
userAgent: `AtlasMCP/${packageJson.version} (${process.platform}; ${process.arch}; ${process.env.HOSTNAME || "unknown"})`,
12-
localDataPath: getLocalDataPath(),
35+
localDataPath,
1336
};
1437

1538
export default config;
1639

17-
function getLocalDataPath() {
40+
function getLocalDataPath(): { localDataPath: string; configPath: string } {
41+
let localDataPath: string | undefined;
42+
let configPath: string | undefined;
43+
1844
if (process.platform === "win32") {
1945
const appData = process.env.APPDATA;
2046
const localAppData = process.env.LOCALAPPDATA ?? process.env.APPDATA;
2147
if (localAppData && appData) {
22-
return path.join(localAppData, "mongodb", "mongodb-mcp");
48+
localDataPath = path.join(localAppData, "mongodb", "mongodb-mcp");
49+
configPath = path.join(localDataPath, "mongodb-mcp.conf");
2350
}
2451
}
2552

26-
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>;
2797
}

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: [

src/tools/mongodb/connect.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { NodeDriverServiceProvider } from "@mongosh/service-provider-node-driver
44
import { DbOperationType, MongoDBToolBase } from "./mongodbTool.js";
55
import { ToolArgs } from "../tool.js";
66
import { ErrorCodes, MongoDBError } from "../../errors.js";
7+
import { saveState } from "../../state.js";
78

89
export class ConnectTool extends MongoDBToolBase {
910
protected name = "connect";
@@ -20,8 +21,8 @@ export class ConnectTool extends MongoDBToolBase {
2021
protected async execute({
2122
connectionStringOrClusterName,
2223
}: ToolArgs<typeof this.argsShape>): Promise<CallToolResult> {
24+
connectionStringOrClusterName ??= this.state.connectionString;
2325
if (!connectionStringOrClusterName) {
24-
// TODO: try reconnecting to the default connection
2526
return {
2627
content: [
2728
{ type: "text", text: "No connection details provided." },
@@ -71,5 +72,7 @@ export class ConnectTool extends MongoDBToolBase {
7172
});
7273

7374
this.mongodbState.serviceProvider = provider;
75+
this.state.connectionString = connectionString;
76+
await saveState(this.state);
7477
}
7578
}

0 commit comments

Comments
 (0)