Skip to content

Commit e869fd3

Browse files
chore: add support for loading config from file MCP-327 (#782)
1 parent 7e32ef1 commit e869fd3

File tree

8 files changed

+265
-30
lines changed

8 files changed

+265
-30
lines changed

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ The MongoDB MCP Server can be configured using multiple methods, with the follow
341341

342342
1. Command-line arguments
343343
2. Environment variables
344+
3. Configuration File
344345

345346
### Configuration Options
346347

@@ -562,6 +563,57 @@ For a full list of roles and their privileges, see the [Atlas User Roles documen
562563

563564
### Configuration Methods
564565

566+
#### Configuration File
567+
568+
Store configuration in a JSON file and load it using the `MDB_MCP_CONFIG` environment variable.
569+
570+
> **🔒 Security Best Practice:** Prefer using the `MDB_MCP_CONFIG` environment variable for sensitive fields over the configuration file or `--config` CLI argument. Command-line arguments are visible in process listings.
571+
572+
> **🔒 File Security:** Ensure your configuration file has proper ownership and permissions, limited to the user running the MongoDB MCP server:
573+
>
574+
> **Linux/macOS:**
575+
>
576+
> ```bash
577+
> chmod 600 /path/to/config.json
578+
> chown your-username /path/to/config.json
579+
> ```
580+
>
581+
> **Windows:** Right-click the file → Properties → Security → Restrict access to your user account only.
582+
583+
Create a JSON file with your configuration (all keys use camelCase):
584+
585+
```json
586+
{
587+
"connectionString": "mongodb://localhost:27017",
588+
"readOnly": true,
589+
"loggers": ["stderr", "mcp"],
590+
"apiClientId": "your-atlas-service-accounts-client-id",
591+
"apiClientSecret": "your-atlas-service-accounts-client-secret",
592+
"maxDocumentsPerQuery": 100
593+
}
594+
```
595+
596+
**Linux/macOS (bash/zsh):**
597+
598+
```bash
599+
export MDB_MCP_CONFIG="/path/to/config.json"
600+
npx -y mongodb-mcp-server@latest
601+
```
602+
603+
**Windows Command Prompt (cmd):**
604+
605+
```cmd
606+
set "MDB_MCP_CONFIG=C:\path\to\config.json"
607+
npx -y mongodb-mcp-server@latest
608+
```
609+
610+
**Windows PowerShell:**
611+
612+
```powershell
613+
$env:MDB_MCP_CONFIG="C:\path\to\config.json"
614+
npx -y mongodb-mcp-server@latest
615+
```
616+
565617
#### Environment Variables
566618

567619
Set environment variables with the prefix `MDB_MCP_` followed by the option name in uppercase with underscores:

src/common/config/createUserConfig.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ function parseUserConfigSources(cliArguments: string[]): {
7474
args: cliArguments,
7575
schema: UserConfigSchema,
7676
parserOptions: {
77+
// This is the name of key that yargs-parser will look up in CLI
78+
// arguments (--config) and ENV variables (MDB_MCP_CONFIG) to load an
79+
// initial configuration from.
80+
config: "config",
7781
// This helps parse the relevant environment variables.
7882
envPrefix: "MDB_MCP_",
7983
configuration: {

src/common/config/userConfig.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ const ServerConfigSchema = z4.object({
178178
.describe(
179179
"API key for Voyage AI embeddings service (required for vector search operations with text-to-embedding conversion)."
180180
)
181-
.register(configRegistry, { isSecret: true, overrideBehavior: "override" }),
181+
.register(configRegistry, { isSecret: true, overrideBehavior: "not-allowed" }),
182182
embeddingsValidation: z4
183183
.preprocess(parseBoolean, z4.boolean())
184184
.default(true)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"connectionString": "mongodb://invalid-value-json-localhost:1000",
3+
"loggers": "stderr,stderr"
4+
}

tests/fixtures/valid-config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"connectionString": "mongodb://valid-json-localhost:1000",
3+
"loggers": "stderr"
4+
}

tests/integration/server.test.ts

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,64 @@
1+
import { MCPConnectionManager } from "../../src/common/connectionManager.js";
2+
import { ExportsManager } from "../../src/common/exportsManager.js";
3+
import { CompositeLogger } from "../../src/common/logger.js";
4+
import { DeviceId } from "../../src/helpers/deviceId.js";
5+
import { Session } from "../../src/common/session.js";
16
import { defaultTestConfig, expectDefined } from "./helpers.js";
27
import { describeWithMongoDB } from "./tools/mongodb/mongodbHelpers.js";
3-
import { describe, expect, it } from "vitest";
8+
import { afterEach, describe, expect, it } from "vitest";
9+
import { Elicitation, Keychain, Telemetry } from "../../src/lib.js";
10+
import { VectorSearchEmbeddingsManager } from "../../src/common/search/vectorSearchEmbeddingsManager.js";
11+
import { defaultCreateAtlasLocalClient } from "../../src/common/atlasLocal.js";
12+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13+
import { Server } from "../../src/server.js";
14+
import { connectionErrorHandler } from "../../src/common/connectionErrorHandler.js";
15+
import { type OperationType, ToolBase, type ToolCategory, type ToolClass } from "../../src/tools/tool.js";
16+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
17+
import type { TelemetryToolMetadata } from "../../src/telemetry/types.js";
18+
import { InMemoryTransport } from "../../src/transports/inMemoryTransport.js";
19+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
20+
21+
class TestToolOne extends ToolBase {
22+
public name = "test-tool-one";
23+
protected description = "A test tool one for verification tests";
24+
static category: ToolCategory = "mongodb";
25+
static operationType: OperationType = "delete";
26+
protected argsShape = {};
27+
protected async execute(): Promise<CallToolResult> {
28+
return Promise.resolve({
29+
content: [
30+
{
31+
type: "text",
32+
text: "Test tool one executed successfully",
33+
},
34+
],
35+
});
36+
}
37+
protected resolveTelemetryMetadata(): TelemetryToolMetadata {
38+
return {};
39+
}
40+
}
41+
42+
class TestToolTwo extends ToolBase {
43+
public name = "test-tool-two";
44+
protected description = "A test tool two for verification tests";
45+
static category: ToolCategory = "mongodb";
46+
static operationType: OperationType = "delete";
47+
protected argsShape = {};
48+
protected async execute(): Promise<CallToolResult> {
49+
return Promise.resolve({
50+
content: [
51+
{
52+
type: "text",
53+
text: "Test tool two executed successfully",
54+
},
55+
],
56+
});
57+
}
58+
protected resolveTelemetryMetadata(): TelemetryToolMetadata {
59+
return {};
60+
}
61+
}
462

563
describe("Server integration test", () => {
664
describeWithMongoDB(
@@ -84,7 +142,7 @@ describe("Server integration test", () => {
84142
// Check that non-read tools are NOT available
85143
expect(tools.tools.some((tool) => tool.name === "insert-many")).toBe(false);
86144
expect(tools.tools.some((tool) => tool.name === "update-many")).toBe(false);
87-
expect(tools.tools.some((tool) => tool.name === "delete-one")).toBe(false);
145+
expect(tools.tools.some((tool) => tool.name === "delete-many")).toBe(false);
88146
expect(tools.tools.some((tool) => tool.name === "drop-collection")).toBe(false);
89147
});
90148
},
@@ -97,4 +155,64 @@ describe("Server integration test", () => {
97155
}),
98156
}
99157
);
158+
159+
describe("with additional tools", () => {
160+
const initServerWithTools = async (tools: ToolClass[]): Promise<{ server: Server; transport: Transport }> => {
161+
const logger = new CompositeLogger();
162+
const deviceId = DeviceId.create(logger);
163+
const connectionManager = new MCPConnectionManager(defaultTestConfig, logger, deviceId);
164+
const exportsManager = ExportsManager.init(defaultTestConfig, logger);
165+
166+
const session = new Session({
167+
userConfig: defaultTestConfig,
168+
logger,
169+
exportsManager,
170+
connectionManager,
171+
keychain: Keychain.root,
172+
vectorSearchEmbeddingsManager: new VectorSearchEmbeddingsManager(defaultTestConfig, connectionManager),
173+
atlasLocalClient: await defaultCreateAtlasLocalClient(),
174+
});
175+
176+
const telemetry = Telemetry.create(session, defaultTestConfig, deviceId);
177+
const mcpServerInstance = new McpServer({ name: "test", version: "1.0" });
178+
const elicitation = new Elicitation({ server: mcpServerInstance.server });
179+
180+
const server = new Server({
181+
session,
182+
userConfig: defaultTestConfig,
183+
telemetry,
184+
mcpServer: mcpServerInstance,
185+
elicitation,
186+
connectionErrorHandler,
187+
tools: [...tools],
188+
});
189+
190+
const transport = new InMemoryTransport();
191+
192+
return { transport, server };
193+
};
194+
195+
let server: Server | undefined;
196+
let transport: Transport | undefined;
197+
198+
afterEach(async () => {
199+
await transport?.close();
200+
});
201+
202+
it("should start server with only the tools provided", async () => {
203+
({ server, transport } = await initServerWithTools([TestToolOne]));
204+
await server.connect(transport);
205+
expect(server.tools).toHaveLength(1);
206+
});
207+
208+
it("should throw error before starting when provided tools have name conflict", async () => {
209+
({ server, transport } = await initServerWithTools([
210+
TestToolOne,
211+
class TestToolTwoButOne extends TestToolTwo {
212+
public name = "test-tool-one";
213+
},
214+
]));
215+
await expect(server.connect(transport)).rejects.toThrow(/Tool test-tool-one is already registered/);
216+
});
217+
});
100218
});

tests/unit/common/config.test.ts

Lines changed: 72 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { Keychain } from "../../../src/common/keychain.js";
1111
import type { Secret } from "../../../src/common/keychain.js";
1212
import { createEnvironment } from "../../utils/index.js";
13+
import path from "path";
1314

1415
// Expected hardcoded values (what we had before)
1516
const expectedDefaults = {
@@ -50,6 +51,11 @@ const expectedDefaults = {
5051
allowRequestOverrides: false,
5152
};
5253

54+
const CONFIG_FIXTURES = {
55+
VALID: path.resolve(import.meta.dirname, "..", "..", "fixtures", "valid-config.json"),
56+
WITH_INVALID_VALUE: path.resolve(import.meta.dirname, "..", "..", "fixtures", "config-with-invalid-value.json"),
57+
};
58+
5359
describe("config", () => {
5460
it("should generate defaults from UserConfigSchema that match expected values", () => {
5561
expect(UserConfigSchema.parse({})).toStrictEqual(expectedDefaults);
@@ -528,6 +534,53 @@ describe("config", () => {
528534
});
529535
});
530536

537+
describe("loading a config file", () => {
538+
describe("through env variable MDB_MCP_CONFIG", () => {
539+
const { setVariable, clearVariables } = createEnvironment();
540+
afterEach(() => {
541+
clearVariables();
542+
});
543+
544+
it("should load a valid config file without troubles", () => {
545+
setVariable("MDB_MCP_CONFIG", CONFIG_FIXTURES.VALID);
546+
const { warnings, error, parsed } = createUserConfig({ args: [] });
547+
expect(warnings).toHaveLength(0);
548+
expect(error).toBeUndefined();
549+
550+
expect(parsed?.connectionString).toBe("mongodb://valid-json-localhost:1000");
551+
expect(parsed?.loggers).toStrictEqual(["stderr"]);
552+
});
553+
554+
it("should attempt loading config file with wrong value and exit", () => {
555+
setVariable("MDB_MCP_CONFIG", CONFIG_FIXTURES.WITH_INVALID_VALUE);
556+
const { warnings, error, parsed } = createUserConfig({ args: [] });
557+
expect(warnings).toHaveLength(0);
558+
expect(error).toEqual(expect.stringContaining("loggers - Duplicate loggers found in config"));
559+
expect(parsed).toBeUndefined();
560+
});
561+
});
562+
563+
describe("through cli argument --config", () => {
564+
it("should load a valid config file without troubles", () => {
565+
const { warnings, error, parsed } = createUserConfig({ args: ["--config", CONFIG_FIXTURES.VALID] });
566+
expect(warnings).toHaveLength(0);
567+
expect(error).toBeUndefined();
568+
569+
expect(parsed?.connectionString).toBe("mongodb://valid-json-localhost:1000");
570+
expect(parsed?.loggers).toStrictEqual(["stderr"]);
571+
});
572+
573+
it("should attempt loading config file with wrong value and exit", () => {
574+
const { warnings, error, parsed } = createUserConfig({
575+
args: ["--config", CONFIG_FIXTURES.WITH_INVALID_VALUE],
576+
});
577+
expect(warnings).toHaveLength(0);
578+
expect(error).toEqual(expect.stringContaining("loggers - Duplicate loggers found in config"));
579+
expect(parsed).toBeUndefined();
580+
});
581+
});
582+
});
583+
531584
describe("precedence rules", () => {
532585
const { setVariable, clearVariables } = createEnvironment();
533586

@@ -538,30 +591,34 @@ describe("config", () => {
538591
it("positional argument takes precedence over all", () => {
539592
setVariable("MDB_MCP_CONNECTION_STRING", "mongodb://crazyhost1");
540593
const { parsed: actual } = createUserConfig({
541-
args: ["mongodb://crazyhost2", "--connectionString", "mongodb://localhost"],
594+
args: [
595+
"mongodb://crazyhost2",
596+
"--config",
597+
CONFIG_FIXTURES.VALID,
598+
"--connectionString",
599+
"mongodb://localhost",
600+
],
542601
});
543602
expect(actual?.connectionString).toBe("mongodb://crazyhost2/?directConnection=true");
544603
});
545604

546-
it("cli arguments take precedence over env vars", () => {
547-
setVariable("MDB_MCP_CONNECTION_STRING", "mongodb://crazyhost");
548-
const { parsed: actual } = createUserConfig({
549-
args: ["--connectionString", "mongodb://localhost"],
605+
it("any cli argument takes precedence over env vars, config and defaults", () => {
606+
setVariable("MDB_MCP_CONNECTION_STRING", "mongodb://dummyhost");
607+
const { parsed } = createUserConfig({
608+
args: ["--config", CONFIG_FIXTURES.VALID, "--connectionString", "mongodb://host-from-cli"],
550609
});
551-
expect(actual?.connectionString).toBe("mongodb://localhost");
610+
expect(parsed?.connectionString).toBe("mongodb://host-from-cli");
552611
});
553612

554-
it("any cli argument takes precedence over defaults", () => {
555-
const { parsed: actual } = createUserConfig({
556-
args: ["--connectionString", "mongodb://localhost"],
557-
});
558-
expect(actual?.connectionString).toBe("mongodb://localhost");
613+
it("any env var takes precedence over config and defaults", () => {
614+
setVariable("MDB_MCP_CONNECTION_STRING", "mongodb://dummyhost");
615+
const { parsed } = createUserConfig({ args: ["--config", CONFIG_FIXTURES.VALID] });
616+
expect(parsed?.connectionString).toBe("mongodb://dummyhost");
559617
});
560618

561-
it("any env var takes precedence over defaults", () => {
562-
setVariable("MDB_MCP_CONNECTION_STRING", "mongodb://localhost");
563-
const { parsed: actual } = createUserConfig({ args: [] });
564-
expect(actual?.connectionString).toBe("mongodb://localhost");
619+
it("config file takes precedence over defaults", () => {
620+
const { parsed } = createUserConfig({ args: ["--config", CONFIG_FIXTURES.VALID] });
621+
expect(parsed?.connectionString).toBe("mongodb://valid-json-localhost:1000");
565622
});
566623
});
567624

tests/unit/common/config/configOverrides.test.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ describe("configOverrides", () => {
230230
"maxDocumentsPerQuery",
231231
"exportsPath",
232232
"exportCleanupIntervalMs",
233+
"voyageApiKey",
233234
"allowRequestOverrides",
234235
"dryRun",
235236
]);
@@ -252,24 +253,19 @@ describe("configOverrides", () => {
252253
});
253254

254255
describe("secret fields", () => {
255-
it("should allow overriding secret fields with headers if they have override behavior", () => {
256-
const request: RequestContext = {
257-
headers: {
258-
"x-mongodb-mcp-voyage-api-key": "test",
259-
},
260-
};
261-
const result = applyConfigOverrides({ baseConfig: baseConfig as UserConfig, request });
262-
expect(result.voyageApiKey).toBe("test");
256+
const secretFields = Object.keys(UserConfigSchema.shape).filter((configKey) => {
257+
const meta = getConfigMeta(configKey as keyof UserConfig);
258+
return meta?.isSecret;
263259
});
264260

265-
it("should not allow overriding secret fields via query params", () => {
261+
it.each(secretFields)("should not allow overriding secret fields - $0", () => {
266262
const request: RequestContext = {
267-
query: {
268-
mongodbMcpVoyageApiKey: "test",
263+
headers: {
264+
"x-mongodb-mcp-voyage-api-key": "test",
269265
},
270266
};
271267
expect(() => applyConfigOverrides({ baseConfig: baseConfig as UserConfig, request })).toThrow(
272-
"Config key voyageApiKey can only be overriden with headers"
268+
"Config key voyageApiKey is not allowed to be overridden"
273269
);
274270
});
275271
});

0 commit comments

Comments
 (0)