From cda83e320fbe5c2dc2be9c72bb117987a52c2a97 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sun, 6 Sep 2026 21:22:50 +0200 Subject: [PATCH] feat(config): support port and host options for HTTP/SSE transport --- src/config/index.ts | 80 ++++++++++++++++++---- tests/config.test.ts | 155 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+), 12 deletions(-) create mode 100644 tests/config.test.ts diff --git a/src/config/index.ts b/src/config/index.ts index 0c1125d..2060b62 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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; +/** + * 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; * @param argv - CLI arguments array (defaults to process.argv) * @returns Validated AppConfig object */ -export function parseConfig( +export function loadConfig( env: Record = 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; + diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..ec6b6d4 --- /dev/null +++ b/tests/config.test.ts @@ -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"); + }); +});