feat(config): support port and host options for HTTP/SSE transport
This commit is contained in:
+68
-12
@@ -3,19 +3,52 @@ import { z } from "zod";
|
||||
/**
|
||||
* Zod schema for OpenProject MCP server configuration.
|
||||
*/
|
||||
export const configSchema = z.object({
|
||||
baseUrl: z
|
||||
.string({ required_error: "OPENPROJECT_BASE_URL is required" })
|
||||
.url("OPENPROJECT_BASE_URL must be a valid URL")
|
||||
.transform((val) => val.replace(/\/+$/, "")),
|
||||
apiKey: z
|
||||
.string({ required_error: "OPENPROJECT_API_KEY is required" })
|
||||
.min(1, "OPENPROJECT_API_KEY must not be empty"),
|
||||
readOnly: z.boolean().default(false),
|
||||
});
|
||||
export const configSchema = z
|
||||
.object({
|
||||
baseUrl: z
|
||||
.string({ required_error: "OPENPROJECT_BASE_URL is required" })
|
||||
.url("OPENPROJECT_BASE_URL must be a valid URL")
|
||||
.transform((val) => val.replace(/\/+$/, "")),
|
||||
apiKey: z
|
||||
.string()
|
||||
.min(1, "OPENPROJECT_API_KEY must not be empty")
|
||||
.optional(),
|
||||
readOnly: z.boolean().default(false),
|
||||
port: z.number().int().min(0).max(65535).optional(),
|
||||
host: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.port === undefined && (!data.apiKey || data.apiKey.trim().length === 0)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "OPENPROJECT_API_KEY is required in stdio mode (when port is not set)",
|
||||
path: ["apiKey"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>;
|
||||
|
||||
/**
|
||||
* Helper to parse a CLI argument flag that may take a value either as
|
||||
* `--flag value` or `--flag=value`.
|
||||
*/
|
||||
function parseCliArg(argv: string[], flag: string): string | undefined {
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (!arg) continue;
|
||||
if (arg === flag) {
|
||||
const nextArg = argv[i + 1];
|
||||
if (nextArg !== undefined && !nextArg.startsWith("-")) {
|
||||
return nextArg;
|
||||
}
|
||||
} else if (arg.startsWith(`${flag}=`)) {
|
||||
return arg.slice(flag.length + 1);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and validates configuration from environment variables and CLI arguments.
|
||||
*
|
||||
@@ -23,7 +56,7 @@ export type AppConfig = z.infer<typeof configSchema>;
|
||||
* @param argv - CLI arguments array (defaults to process.argv)
|
||||
* @returns Validated AppConfig object
|
||||
*/
|
||||
export function parseConfig(
|
||||
export function loadConfig(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
argv: string[] = process.argv
|
||||
): AppConfig {
|
||||
@@ -35,9 +68,32 @@ export function parseConfig(
|
||||
readOnlyEnv === "1" ||
|
||||
readOnlyEnv === "yes";
|
||||
|
||||
const portArg = parseCliArg(argv, "--port");
|
||||
const portStr = portArg ?? env.PORT;
|
||||
let port: number | undefined;
|
||||
if (portStr !== undefined && portStr.trim() !== "") {
|
||||
const parsed = parseInt(portStr.trim(), 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error(`Invalid port value: ${portStr}`);
|
||||
}
|
||||
port = parsed;
|
||||
}
|
||||
|
||||
const hostArg = parseCliArg(argv, "--host");
|
||||
const hostStr = hostArg ?? env.HOST;
|
||||
const host = hostStr !== undefined && hostStr.trim() !== "" ? hostStr.trim() : undefined;
|
||||
|
||||
const apiKeyRaw = env.OPENPROJECT_API_KEY?.trim();
|
||||
const apiKey = apiKeyRaw !== undefined && apiKeyRaw.length > 0 ? apiKeyRaw : undefined;
|
||||
|
||||
return configSchema.parse({
|
||||
baseUrl: env.OPENPROJECT_BASE_URL,
|
||||
apiKey: env.OPENPROJECT_API_KEY,
|
||||
apiKey,
|
||||
readOnly: isReadOnly,
|
||||
port,
|
||||
host,
|
||||
});
|
||||
}
|
||||
|
||||
export const parseConfig = loadConfig;
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
||||
import { loadConfig, parseConfig } from "../src/config/index.ts";
|
||||
|
||||
describe("Configuration Loader (HTTP & Stdio Support)", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const originalArgv = [...process.argv];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
process.argv = [...originalArgv.slice(0, 2)];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
test("loads stdio configuration when PORT is unset and OPENPROJECT_API_KEY is provided", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
|
||||
process.env.OPENPROJECT_API_KEY = "test-key";
|
||||
delete process.env.PORT;
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.baseUrl).toBe("https://openproject.example.com");
|
||||
expect(config.apiKey).toBe("test-key");
|
||||
expect(config.readOnly).toBe(false);
|
||||
expect(config.port).toBeUndefined();
|
||||
});
|
||||
|
||||
test("throws error if OPENPROJECT_API_KEY is missing in stdio mode", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
|
||||
delete process.env.OPENPROJECT_API_KEY;
|
||||
delete process.env.PORT;
|
||||
|
||||
expect(() => loadConfig()).toThrow();
|
||||
});
|
||||
|
||||
test("throws error if OPENPROJECT_API_KEY is whitespace in stdio mode", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
|
||||
process.env.OPENPROJECT_API_KEY = " ";
|
||||
delete process.env.PORT;
|
||||
|
||||
expect(() => loadConfig()).toThrow();
|
||||
});
|
||||
|
||||
test("throws error if OPENPROJECT_BASE_URL is invalid", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "not-a-valid-url";
|
||||
process.env.OPENPROJECT_API_KEY = "test-key";
|
||||
delete process.env.PORT;
|
||||
|
||||
expect(() => loadConfig()).toThrow();
|
||||
});
|
||||
|
||||
test("normalizes baseUrl by stripping trailing slashes", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com///";
|
||||
process.env.OPENPROJECT_API_KEY = "test-key";
|
||||
delete process.env.PORT;
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.baseUrl).toBe("https://openproject.example.com");
|
||||
});
|
||||
|
||||
test("loads HTTP configuration when PORT environment variable is set without API key", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
|
||||
delete process.env.OPENPROJECT_API_KEY;
|
||||
process.env.PORT = "3000";
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.baseUrl).toBe("https://openproject.example.com");
|
||||
expect(config.port).toBe(3000);
|
||||
expect(config.apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test("loads HTTP configuration with PORT=0 for dynamic assignment", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
|
||||
delete process.env.OPENPROJECT_API_KEY;
|
||||
process.env.PORT = "0";
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.baseUrl).toBe("https://openproject.example.com");
|
||||
expect(config.port).toBe(0);
|
||||
expect(config.apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
test("loads HTTP configuration with API key when provided", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
|
||||
process.env.OPENPROJECT_API_KEY = "test-key";
|
||||
process.env.PORT = "3000";
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.baseUrl).toBe("https://openproject.example.com");
|
||||
expect(config.port).toBe(3000);
|
||||
expect(config.apiKey).toBe("test-key");
|
||||
});
|
||||
|
||||
test("parses --port and --host CLI arguments", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
|
||||
delete process.env.OPENPROJECT_API_KEY;
|
||||
delete process.env.PORT;
|
||||
process.argv.push("--port", "8080", "--host", "127.0.0.1", "--read-only");
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.port).toBe(8080);
|
||||
expect(config.host).toBe("127.0.0.1");
|
||||
expect(config.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
test("parses --port=value and --host=value CLI arguments", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
|
||||
delete process.env.OPENPROJECT_API_KEY;
|
||||
delete process.env.PORT;
|
||||
process.argv.push("--port=9090", "--host=0.0.0.0");
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.port).toBe(9090);
|
||||
expect(config.host).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
test("CLI arguments take precedence over environment variables", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
|
||||
process.env.PORT = "3000";
|
||||
process.env.HOST = "127.0.0.1";
|
||||
process.argv.push("--port", "4000", "--host", "0.0.0.0");
|
||||
|
||||
const config = loadConfig();
|
||||
expect(config.port).toBe(4000);
|
||||
expect(config.host).toBe("0.0.0.0");
|
||||
});
|
||||
|
||||
test("throws error if port is non-numeric", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
|
||||
process.env.PORT = "not-a-port";
|
||||
|
||||
expect(() => loadConfig()).toThrow();
|
||||
});
|
||||
|
||||
test("throws error if port is out of range", () => {
|
||||
process.env.OPENPROJECT_BASE_URL = "https://openproject.example.com";
|
||||
process.env.PORT = "70000";
|
||||
|
||||
expect(() => loadConfig()).toThrow();
|
||||
});
|
||||
|
||||
test("parseConfig alias works identically to loadConfig", () => {
|
||||
const config = parseConfig(
|
||||
{
|
||||
OPENPROJECT_BASE_URL: "https://openproject.example.com",
|
||||
OPENPROJECT_API_KEY: "alias-key",
|
||||
},
|
||||
[]
|
||||
);
|
||||
expect(config.baseUrl).toBe("https://openproject.example.com");
|
||||
expect(config.apiKey).toBe("alias-key");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user