Skip to content

Commit 832c0b9

Browse files
committed
feat(config): add MCP initialization config handling and prioritization (#12)
Introduce a handler extracting configuration from MCP client info during initialization, enabling dynamic config setup based on client-provided data. Update configuration loader to prioritize MCP init config over environment variables and other sources, ensuring runtime flexibility and seamless integration with MCP clients. Add comprehensive tests verifying MCP config precedence, boolean parsing, nested config handling, and hostname extraction. Fixes #7
1 parent 30c72ed commit 832c0b9

4 files changed

Lines changed: 255 additions & 14 deletions

File tree

src/index.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ export async function startServer(
5353
title: "Lokalise MCP Server",
5454
});
5555

56+
// Set up MCP initialization handler to extract configuration
57+
setupMcpInitializationHandler(serverInstance);
58+
5659
serverLogger.info("Registering MCP tools, resources, and prompts...");
5760
await registerAllTools(serverInstance);
5861
await registerAllResources(serverInstance);
@@ -86,6 +89,34 @@ export async function startServer(
8689
transportInstance = transport;
8790

8891
app.all(mcpEndpoint, (req: Request, res: Response) => {
92+
// Extract query parameters for Smithery configuration
93+
const queryConfig = req.query;
94+
if (Object.keys(queryConfig).length > 0) {
95+
serverLogger.debug("Received query parameters", { queryConfig });
96+
97+
// Parse dot-notation query parameters into nested objects
98+
const parsedConfig: Record<string, unknown> = {};
99+
for (const [key, value] of Object.entries(queryConfig)) {
100+
// Handle dot notation (e.g., server.host -> { server: { host: value } })
101+
const parts = key.split(".");
102+
let current = parsedConfig;
103+
104+
for (let i = 0; i < parts.length - 1; i++) {
105+
const part = parts[i];
106+
if (!(part in current)) {
107+
current[part] = {};
108+
}
109+
current = current[part] as Record<string, unknown>;
110+
}
111+
112+
// Set the final value
113+
current[parts[parts.length - 1]] = value;
114+
}
115+
116+
// Set the HTTP query configuration with highest priority
117+
config.setHttpQueryConfig(parsedConfig);
118+
}
119+
89120
transport.handleRequest(req, res, req.body).catch((err: unknown) => {
90121
serverLogger.error("Error in transport.handleRequest", err);
91122
if (!res.headersSent) {
@@ -219,3 +250,56 @@ function setupGracefulShutdown() {
219250
});
220251
});
221252
}
253+
254+
/**
255+
* Set up MCP initialization handler to extract configuration from clientInfo
256+
*/
257+
function setupMcpInitializationHandler(server: McpServer): void {
258+
const initLogger = Logger.forContext(
259+
"index.ts",
260+
"setupMcpInitializationHandler",
261+
);
262+
263+
// Store the original oninitialized callback
264+
const originalOnInitialized = server.server.oninitialized;
265+
266+
// Set up a custom oninitialized callback to extract configuration
267+
server.server.oninitialized = () => {
268+
initLogger.debug("MCP initialization completed");
269+
270+
// Try to extract configuration from the client info
271+
const clientVersion = server.server.getClientVersion();
272+
if (clientVersion && typeof clientVersion === "object") {
273+
// Look for configuration in clientInfo - Smithery typically passes config here
274+
const configData: Record<string, unknown> = {};
275+
276+
// Extract all non-standard fields from clientInfo as potential config
277+
for (const [key, value] of Object.entries(clientVersion)) {
278+
if (!["name", "version", "title"].includes(key)) {
279+
configData[key] = value;
280+
}
281+
}
282+
283+
// Also check for explicit config object
284+
if (
285+
"config" in clientVersion &&
286+
typeof clientVersion.config === "object" &&
287+
clientVersion.config
288+
) {
289+
Object.assign(configData, clientVersion.config);
290+
}
291+
292+
if (Object.keys(configData).length > 0) {
293+
initLogger.info("Extracted configuration from MCP initialization", {
294+
configKeys: Object.keys(configData),
295+
});
296+
config.setMcpInitConfig(configData);
297+
}
298+
}
299+
300+
// Call the original callback if it existed
301+
if (originalOnInitialized) {
302+
originalOnInitialized();
303+
}
304+
};
305+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { beforeEach, describe, expect, test } from "@jest/globals";
2+
import { config } from "./config.util.js";
3+
4+
describe("ConfigLoader MCP Initialization", () => {
5+
beforeEach(() => {
6+
// Reset the config instance for each test
7+
// biome-ignore lint/suspicious/noExplicitAny: Needed for testing private properties
8+
(config as any).configLoaded = false;
9+
// biome-ignore lint/suspicious/noExplicitAny: Needed for testing private properties
10+
(config as any).mcpInitConfig = {};
11+
12+
// Clear any environment variables that might interfere
13+
delete process.env.TEST_KEY;
14+
delete process.env.LOKALISE_API_KEY;
15+
});
16+
17+
test("should prioritize MCP init config over environment variables", () => {
18+
// Set up environment variable
19+
process.env.TEST_KEY = "env_value";
20+
21+
// Set up MCP init config (should have higher priority)
22+
config.setMcpInitConfig({ TEST_KEY: "mcp_value" });
23+
24+
// Load config from all sources
25+
config.load();
26+
27+
// MCP config should take precedence
28+
expect(config.get("TEST_KEY")).toBe("mcp_value");
29+
});
30+
31+
test("should fall back to environment variables when MCP config is not set", () => {
32+
// Set up environment variable
33+
process.env.TEST_KEY = "env_value";
34+
35+
// Don't set MCP config for this key
36+
config.setMcpInitConfig({});
37+
38+
// Load config from all sources
39+
config.load();
40+
41+
// Should fall back to environment variable
42+
expect(config.get("TEST_KEY")).toBe("env_value");
43+
});
44+
45+
test("should return default value when key is not found in any source", () => {
46+
// Don't set the key anywhere
47+
config.setMcpInitConfig({});
48+
config.load();
49+
50+
// Should return the default value
51+
expect(config.get("NONEXISTENT_KEY", "default_value")).toBe(
52+
"default_value",
53+
);
54+
});
55+
56+
test("should handle boolean values from MCP config", () => {
57+
// Set up MCP init config with boolean-like values
58+
config.setMcpInitConfig({
59+
BOOL_TRUE: "true",
60+
BOOL_FALSE: "false",
61+
BOOL_INVALID: "invalid",
62+
});
63+
64+
config.load();
65+
66+
expect(config.getBoolean("BOOL_TRUE")).toBe(true);
67+
expect(config.getBoolean("BOOL_FALSE")).toBe(false);
68+
expect(config.getBoolean("BOOL_INVALID")).toBe(false);
69+
});
70+
71+
test("should handle nested config object in MCP init", () => {
72+
// Simulate what Smithery might pass
73+
const mcpConfig = {
74+
LOKALISE_API_KEY: "test_api_key",
75+
LOKALISE_API_HOSTNAME: "https://api.example.com/api2/",
76+
DEBUG: "true",
77+
};
78+
79+
config.setMcpInitConfig(mcpConfig);
80+
config.load();
81+
82+
expect(config.get("LOKALISE_API_KEY")).toBe("test_api_key");
83+
expect(config.get("LOKALISE_API_HOSTNAME")).toBe(
84+
"https://api.example.com/api2/",
85+
);
86+
expect(config.getBoolean("DEBUG")).toBe(true);
87+
});
88+
89+
test("should extract hostname correctly from MCP config", () => {
90+
config.setMcpInitConfig({
91+
LOKALISE_API_HOSTNAME: "https://api.stage.lokalise.cloud/api2/",
92+
});
93+
94+
config.load();
95+
96+
expect(config.getLokaliseHostname()).toBe("stage.lokalise.cloud");
97+
});
98+
});

src/shared/utils/config.util.ts

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,16 @@ import { Logger } from "./logger.util.js";
66

77
/**
88
* Configuration loader that handles multiple sources with priority:
9-
* 1. Direct ENV pass (process.env)
10-
* 2. .env file in project root
11-
* 3. Global config file at $HOME/.mcp/configs.json
9+
* 1. MCP initialization config (from clientInfo)
10+
* 2. Direct ENV pass (process.env)
11+
* 3. .env file in project root
12+
* 4. Global config file at $HOME/.mcp/configs.json
1213
*/
1314
class ConfigLoader {
1415
private packageName: string;
1516
private configLoaded = false;
17+
private mcpInitConfig: Record<string, unknown> = {};
18+
private httpQueryConfig: Record<string, unknown> = {};
1619

1720
/**
1821
* Create a new ConfigLoader instance
@@ -22,6 +25,39 @@ class ConfigLoader {
2225
this.packageName = packageName;
2326
}
2427

28+
/**
29+
* Set configuration from HTTP query parameters (highest priority for Smithery)
30+
* @param config Configuration object from HTTP query parameters
31+
*/
32+
setHttpQueryConfig(config: Record<string, unknown>): void {
33+
const methodLogger = Logger.forContext(
34+
"utils/config.util.ts",
35+
"setHttpQueryConfig",
36+
);
37+
38+
this.httpQueryConfig = { ...config };
39+
methodLogger.debug("HTTP query parameter configuration set", {
40+
keysCount: Object.keys(config).length,
41+
keys: Object.keys(config),
42+
});
43+
}
44+
45+
/**
46+
* Set configuration from MCP initialization (second highest priority)
47+
* @param config Configuration object from MCP clientInfo
48+
*/
49+
setMcpInitConfig(config: Record<string, unknown>): void {
50+
const methodLogger = Logger.forContext(
51+
"utils/config.util.ts",
52+
"setMcpInitConfig",
53+
);
54+
55+
this.mcpInitConfig = { ...config };
56+
methodLogger.debug("MCP initialization configuration set", {
57+
keysCount: Object.keys(config).length,
58+
});
59+
}
60+
2561
/**
2662
* Load configuration from all sources with proper priority
2763
*/
@@ -35,15 +71,21 @@ class ConfigLoader {
3571

3672
methodLogger.debug("Loading configuration...");
3773

38-
// Priority 3: Load from global config file
74+
// Priority 5: Load from global config file
3975
this.loadFromGlobalConfig();
4076

41-
// Priority 2: Load from .env file
77+
// Priority 4: Load from .env file
4278
this.loadFromEnvFile();
4379

44-
// Priority 1: Direct ENV pass is already in process.env
80+
// Priority 3: Direct ENV pass is already in process.env
4581
// No need to do anything as it already has highest priority
4682

83+
// Priority 2: MCP init config is handled in get() method
84+
// No need to set process.env as it might conflict with existing values
85+
86+
// Priority 1: HTTP query config is handled in get() method
87+
// This has the highest priority for Smithery deployments
88+
4789
this.configLoaded = true;
4890
methodLogger.debug("Configuration loaded successfully");
4991
}
@@ -91,12 +133,11 @@ class ConfigLoader {
91133
const config = JSON.parse(configContent);
92134

93135
// Determine the potential keys for the current package
94-
const shortKey = "boilerplate"; // Project-specific short key
95-
const fullPackageName = this.packageName; // e.g., '@aashari/boilerplate-mcp-server'
136+
const fullPackageName = this.packageName; // e.g., 'lokalise-mcp'
96137
const unscopedPackageName =
97-
fullPackageName.split("/")[1] || fullPackageName; // e.g., 'boilerplate-mcp-server'
138+
fullPackageName.split("/")[1] || fullPackageName; // e.g., 'lokalise-mcp'
98139

99-
const potentialKeys = [shortKey, fullPackageName, unscopedPackageName];
140+
const potentialKeys = [fullPackageName, unscopedPackageName];
100141
let foundConfigSection: {
101142
environments?: Record<string, unknown>;
102143
} | null = null;
@@ -145,6 +186,17 @@ class ConfigLoader {
145186
* @returns The configuration value or the default value
146187
*/
147188
get(key: string, defaultValue?: string): string | undefined {
189+
// Priority 1: HTTP query parameters (highest priority for Smithery)
190+
if (this.httpQueryConfig[key] !== undefined) {
191+
return String(this.httpQueryConfig[key]);
192+
}
193+
194+
// Priority 2: MCP initialization config
195+
if (this.mcpInitConfig[key] !== undefined) {
196+
return String(this.mcpInitConfig[key]);
197+
}
198+
199+
// Priority 3: Environment variables
148200
return process.env[key] || defaultValue;
149201
}
150202

@@ -196,4 +248,4 @@ class ConfigLoader {
196248
}
197249

198250
// Create and export a singleton instance with the package name from package.json
199-
export const config = new ConfigLoader("@aashari/boilerplate-mcp-server");
251+
export const config = new ConfigLoader("lokalise-mcp");

src/shared/utils/transport.util.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,16 @@ export async function fetchLokaliseApi<T>(
184184
// Get credentials (token is required)
185185
const credentials = getLokaliseApiCredentials();
186186

187-
// Construct the full URL
188-
const baseUrl = "https://api.stage.lokalise.cloud/api2//api2";
189-
const url = `${baseUrl}${path}`;
187+
// Get the API hostname from configuration
188+
const apiHostname =
189+
config.get("LOKALISE_API_HOSTNAME") || "https://api.lokalise.com/api2/";
190+
191+
// Ensure the hostname ends with a slash and remove any duplicate slashes
192+
const baseUrl = apiHostname.endsWith("/")
193+
? apiHostname.slice(0, -1)
194+
: apiHostname;
195+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
196+
const url = `${baseUrl}${normalizedPath}`;
190197

191198
methodLogger.debug(`Constructed Lokalise API URL: ${url}`);
192199

0 commit comments

Comments
 (0)