feat(config): support port and host options for HTTP/SSE transport

This commit is contained in:
Lucas Winther
2026-09-06 21:22:50 +02:00
parent 2fad0036e7
commit cda83e320f
2 changed files with 223 additions and 12 deletions
+68 -12
View File
@@ -3,19 +3,52 @@ import { z } from "zod";
/** /**
* Zod schema for OpenProject MCP server configuration. * Zod schema for OpenProject MCP server configuration.
*/ */
export const configSchema = z.object({ export const configSchema = z
baseUrl: z .object({
.string({ required_error: "OPENPROJECT_BASE_URL is required" }) baseUrl: z
.url("OPENPROJECT_BASE_URL must be a valid URL") .string({ required_error: "OPENPROJECT_BASE_URL is required" })
.transform((val) => val.replace(/\/+$/, "")), .url("OPENPROJECT_BASE_URL must be a valid URL")
apiKey: z .transform((val) => val.replace(/\/+$/, "")),
.string({ required_error: "OPENPROJECT_API_KEY is required" }) apiKey: z
.min(1, "OPENPROJECT_API_KEY must not be empty"), .string()
readOnly: z.boolean().default(false), .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>; 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. * 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) * @param argv - CLI arguments array (defaults to process.argv)
* @returns Validated AppConfig object * @returns Validated AppConfig object
*/ */
export function parseConfig( export function loadConfig(
env: Record<string, string | undefined> = process.env, env: Record<string, string | undefined> = process.env,
argv: string[] = process.argv argv: string[] = process.argv
): AppConfig { ): AppConfig {
@@ -35,9 +68,32 @@ export function parseConfig(
readOnlyEnv === "1" || readOnlyEnv === "1" ||
readOnlyEnv === "yes"; 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({ return configSchema.parse({
baseUrl: env.OPENPROJECT_BASE_URL, baseUrl: env.OPENPROJECT_BASE_URL,
apiKey: env.OPENPROJECT_API_KEY, apiKey,
readOnly: isReadOnly, readOnly: isReadOnly,
port,
host,
}); });
} }
export const parseConfig = loadConfig;
+155
View File
@@ -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");
});
});