diff --git a/.env-example b/.env-example index 0aab883..9fb62a5 100644 --- a/.env-example +++ b/.env-example @@ -4,7 +4,7 @@ VOIPMS_API_USERNAME= VOIPMS_API_PASSWORD= -DEFAULT_TIMEZONE=-4 +VOIPMS_TIMEZONE=-4 # The default "from" number to be used for sending texts, placing calls, etc MY_DEFAULT_DID= diff --git a/README.md b/README.md index 70a96f6..6af61db 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,15 @@ export VOIPMS_API_USERNAME='you@example.com' export VOIPMS_API_PASSWORD='your-api-password' ``` +It also loads a project-root `.env` file when present: + +```bash +cp .env-example .env +``` + +Values already exported in the process environment, and values passed with CLI +options, take precedence over `.env` values. + Credentials can also be passed directly: ```bash @@ -51,6 +60,7 @@ Credentials can also be passed directly: ``` The endpoint can be overridden with `VOIPMS_API_ENDPOINT` or `--endpoint`. +CDR timezone defaults can be set with `VOIPMS_TIMEZONE`. ## CLI Overview @@ -261,9 +271,8 @@ be run behind a trusted network, firewall, VPN, or tunnel. Start it with: ```bash -export VOIPMS_API_USERNAME='you@example.com' -export VOIPMS_API_PASSWORD='your-api-password' -export MCP_AUTH_TOKEN='choose-a-long-random-token' +cp .env-example .env +# Edit .env and optionally add MCP_AUTH_TOKEN='choose-a-long-random-token' ./bin/serve-mcp.sh ``` diff --git a/bin/serve-mcp.sh b/bin/serve-mcp.sh index fdc7eb9..0239a62 100755 --- a/bin/serve-mcp.sh +++ b/bin/serve-mcp.sh @@ -5,6 +5,48 @@ HOST="${MCP_HOST:-0.0.0.0}" PORT="${MCP_PORT:-8787}" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +load_env_file() { + local env_file="$1" + local line key value first last comment_start + + [[ -r "${env_file}" ]] || return 0 + + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line%"${line##*[![:space:]]}"}" + + [[ -z "${line}" || "${line}" == \#* || "${line}" != *=* ]] && continue + + key="${line%%=*}" + value="${line#*=}" + key="${key%"${key##*[![:space:]]}"}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + [[ "${key}" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue + [[ -v "${key}" ]] && continue + + first="${value:0:1}" + last="${value: -1}" + if [[ "${#value}" -ge 2 && ( ( "${first}" == "'" && "${last}" == "'" ) || ( "${first}" == '"' && "${last}" == '"' ) ) ]]; then + value="${value:1:${#value}-2}" + else + comment_start="${value%%" #"*}" + if [[ "${comment_start}" != "${value}" ]]; then + value="${comment_start}" + value="${value%"${value##*[![:space:]]}"}" + fi + fi + + export "${key}=${value}" + done < "${env_file}" +} + +load_env_file "${ROOT}/.env" + +HOST="${MCP_HOST:-${HOST}}" +PORT="${MCP_PORT:-${PORT}}" + if [[ "${MCP_ALLOW_UNAUTHENTICATED:-}" != "1" && -z "${MCP_AUTH_TOKEN:-}" ]]; then MCP_AUTH_TOKEN="$(php -r 'echo bin2hex(random_bytes(24));')" export MCP_AUTH_TOKEN diff --git a/docs/mcp.md b/docs/mcp.md index 6178c16..c5764b1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9,13 +9,15 @@ so use a firewall, VPN, or private network when exposing it beyond this machine. ## Start The Server ```bash -export VOIPMS_API_USERNAME='you@example.com' -export VOIPMS_API_PASSWORD='your-api-password' -export MCP_AUTH_TOKEN='choose-a-long-random-token' +cp .env-example .env +# Edit .env and optionally add MCP_AUTH_TOKEN='choose-a-long-random-token' ./bin/serve-mcp.sh ``` +The launcher reads the project-root `.env` file when present. Already exported +environment variables take precedence over `.env` values. + Defaults: ```text diff --git a/mcp/http-server.php b/mcp/http-server.php index b70fdfe..780fb4b 100644 --- a/mcp/http-server.php +++ b/mcp/http-server.php @@ -3,10 +3,14 @@ declare(strict_types=1); require_once __DIR__ . '/../src/VoipMsClient.php'; +require_once __DIR__ . '/../src/EnvLoader.php'; +use VoipMs\EnvLoader; use VoipMs\VoipMsClient; use VoipMs\VoipMsResponse; +EnvLoader::load(__DIR__ . '/../.env'); + const MCP_PROTOCOL_VERSION = '2025-06-18'; const MCP_SESSION_ID = 'voipms-test-session'; @@ -229,7 +233,7 @@ function callTool(string $name, array $arguments): array 'voipms_get_recent_cdr' => responsePayload($client->getCdr( requiredStringArg($arguments, 'date_from'), requiredStringArg($arguments, 'date_to'), - stringArg($arguments, 'timezone', getenv('VOIPMS_TIMEZONE') ?: '-4'), + stringArg($arguments, 'timezone', getenv('VOIPMS_TIMEZONE') ?: getenv('DEFAULT_TIMEZONE') ?: '-4'), cdrFilters($arguments), )), 'voipms_add_number_to_phonebook_group' => addNumberToPhonebookGroupTool($client, $arguments), diff --git a/src/EnvLoader.php b/src/EnvLoader.php new file mode 100644 index 0000000..3d02668 --- /dev/null +++ b/src/EnvLoader.php @@ -0,0 +1,85 @@ +getMessage()}\n"); + exit(1); + } +} + +function assertSameValue(mixed $expected, mixed $actual, string $message): void +{ + if ($expected !== $actual) { + throw new RuntimeException($message . ' Expected ' . var_export($expected, true) . ', got ' . var_export($actual, true) . '.'); + } +} + +function clearEnvKey(string $key): void +{ + putenv($key); + unset($_ENV[$key], $_SERVER[$key]); +} + +/** + * @param list $lines + */ +function withTempEnvFile(array $lines): string +{ + $path = tempnam(sys_get_temp_dir(), 'voipms-env-'); + if ($path === false) { + throw new RuntimeException('Unable to create temp env file.'); + } + + file_put_contents($path, implode("\n", $lines) . "\n"); + + return $path; +} + +runTest('loads simple and quoted .env values', function (): void { + foreach (['VOIPMS_TEST_SIMPLE', 'VOIPMS_TEST_SINGLE', 'VOIPMS_TEST_DOUBLE', 'VOIPMS_TEST_EMPTY'] as $key) { + clearEnvKey($key); + } + + $path = withTempEnvFile([ + '# comment', + '', + 'VOIPMS_TEST_SIMPLE=value', + "VOIPMS_TEST_SINGLE='single quoted value'", + 'VOIPMS_TEST_DOUBLE="double quoted value"', + 'VOIPMS_TEST_EMPTY=', + ]); + + try { + EnvLoader::load($path); + + assertSameValue('value', getenv('VOIPMS_TEST_SIMPLE'), 'Simple value should be available through getenv().'); + assertSameValue('single quoted value', $_ENV['VOIPMS_TEST_SINGLE'] ?? null, 'Single quoted value should be available through $_ENV.'); + assertSameValue('double quoted value', $_SERVER['VOIPMS_TEST_DOUBLE'] ?? null, 'Double quoted value should be available through $_SERVER.'); + assertSameValue('', getenv('VOIPMS_TEST_EMPTY'), 'Empty value should be loaded.'); + } finally { + unlink($path); + foreach (['VOIPMS_TEST_SIMPLE', 'VOIPMS_TEST_SINGLE', 'VOIPMS_TEST_DOUBLE', 'VOIPMS_TEST_EMPTY'] as $key) { + clearEnvKey($key); + } + } +}); + +runTest('does not override existing process environment', function (): void { + clearEnvKey('VOIPMS_TEST_OVERRIDE'); + putenv('VOIPMS_TEST_OVERRIDE=from-process'); + $_ENV['VOIPMS_TEST_OVERRIDE'] = 'from-process'; + $_SERVER['VOIPMS_TEST_OVERRIDE'] = 'from-process'; + + $path = withTempEnvFile([ + 'VOIPMS_TEST_OVERRIDE=from-file', + ]); + + try { + EnvLoader::load($path); + + assertSameValue('from-process', getenv('VOIPMS_TEST_OVERRIDE'), 'Existing getenv() value should win.'); + assertSameValue('from-process', $_ENV['VOIPMS_TEST_OVERRIDE'] ?? null, 'Existing $_ENV value should win.'); + assertSameValue('from-process', $_SERVER['VOIPMS_TEST_OVERRIDE'] ?? null, 'Existing $_SERVER value should win.'); + } finally { + unlink($path); + clearEnvKey('VOIPMS_TEST_OVERRIDE'); + } +}); + +runTest('ignores missing .env files', function (): void { + EnvLoader::load(sys_get_temp_dir() . '/voipms-missing-env-file'); +}); diff --git a/voipms-cli.php b/voipms-cli.php index 4dd6237..771c315 100755 --- a/voipms-cli.php +++ b/voipms-cli.php @@ -5,9 +5,13 @@ declare(strict_types=1); require_once __DIR__ . '/src/VoipMsResponse.php'; require_once __DIR__ . '/src/VoipMsClient.php'; +require_once __DIR__ . '/src/EnvLoader.php'; +use VoipMs\EnvLoader; use VoipMs\VoipMsClient; +EnvLoader::load(__DIR__ . '/.env'); + /** * @return array, examples?: list}> */ @@ -624,7 +628,7 @@ function parseCdrArguments(array $args): array $params['date_to'] = $positionals[1]; } - $envTimezone = getenv('VOIPMS_TIMEZONE'); + $envTimezone = getenv('VOIPMS_TIMEZONE') ?: getenv('DEFAULT_TIMEZONE'); if (!isset($params['timezone']) && is_string($envTimezone) && $envTimezone !== '') { $params['timezone'] = $envTimezone; }