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.
|
* 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;
|
||||||
|
|
||||||
|
|||||||
@@ -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