feat: add environment configuration loader
This commit is contained in:
+4
-6
@@ -7,7 +7,7 @@ ChatGPT MCP Server
|
|||||||
## Status
|
## Status
|
||||||
|
|
||||||
Planning complete.
|
Planning complete.
|
||||||
Phase 0 implementation in progress.
|
Phase 0 complete. Phase 1 in progress.
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
|
|
||||||
@@ -16,11 +16,9 @@ Phase 0 - Repository Setup
|
|||||||
## Completed Tasks
|
## Completed Tasks
|
||||||
|
|
||||||
- Task 0.1 — Create repository skeleton ✅
|
- Task 0.1 — Create repository skeleton ✅
|
||||||
|
- Task 0.2 — package.json with dependencies ✅
|
||||||
## In Progress
|
- Task 1.1 — Configuration loader ✅
|
||||||
|
|
||||||
- Task 0.2 — package.json created, dependencies not yet installed
|
|
||||||
|
|
||||||
## Next Task
|
## Next Task
|
||||||
|
|
||||||
Task 0.2 (continued) — Add package.json and dependencies
|
Task 1.2 — Secret redaction utility (`src/utils/redact.js`)
|
||||||
|
|||||||
@@ -23,4 +23,62 @@ Status: ✅ Complete
|
|||||||
|
|
||||||
Add `package.json` with MCP SDK, OpenAI SDK, Zod, Vitest.
|
Add `package.json` with MCP SDK, OpenAI SDK, Zod, Vitest.
|
||||||
|
|
||||||
|
Status: ✅ Complete
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 - Core Utilities
|
||||||
|
|
||||||
|
### Task 1.1 - Configuration loader
|
||||||
|
|
||||||
|
Create `src/config/env.js`.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Read environment variables and validate OPENAI_API_KEY exists.
|
||||||
|
- Apply defaults from ARCHITECTURE.md section 13.
|
||||||
|
- Return a structured config object.
|
||||||
|
- Throw clear configuration errors for missing or invalid values.
|
||||||
|
- Use plain JavaScript validation (no Zod yet).
|
||||||
|
- Single exported `loadConfig()` function.
|
||||||
|
|
||||||
|
Create `test/config/env.test.js`.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Test all default values.
|
||||||
|
- Test all custom overrides (strings, numbers, booleans).
|
||||||
|
- Test error cases: missing API key, invalid numbers, invalid booleans.
|
||||||
|
- No external dependencies in the loader.
|
||||||
|
|
||||||
|
Status: ✅ Complete
|
||||||
|
|
||||||
|
### Task 1.2 - Secret redaction utility
|
||||||
|
|
||||||
|
Create `src/utils/redact.js`.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Detect likely secrets (API keys, tokens, passwords, Bearer tokens, private keys, SSH keys, PEM blocks, DB URLs with credentials).
|
||||||
|
- Replace detected secrets with `[REDACTED]`.
|
||||||
|
- Return the redacted string.
|
||||||
|
|
||||||
|
Status: ⏳ Pending
|
||||||
|
|
||||||
|
### Task 1.3 - Context budget utility
|
||||||
|
|
||||||
|
Create `src/utils/context-budget.js`.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Character limit checks (max total, per file, max files).
|
||||||
|
- Priority-based trimming when limits exceeded.
|
||||||
|
- Reject oversized input with safe message.
|
||||||
|
|
||||||
|
Status: ⏳ Pending
|
||||||
|
|
||||||
|
### Task 1.4 - Error formatting utility
|
||||||
|
|
||||||
|
Create `src/utils/errors.js` and `src/utils/logging.js`.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Safe error message formatting (no stack traces).
|
||||||
|
- Minimal logging to stderr only.
|
||||||
|
|
||||||
Status: ⏳ Pending
|
Status: ⏳ Pending
|
||||||
|
|||||||
Generated
+3001
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"test": "echo \"No tests yet\""
|
"test": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.7.0",
|
"@modelcontextprotocol/sdk": "^1.7.0",
|
||||||
|
|||||||
@@ -1 +1,44 @@
|
|||||||
// Environment variable loading and validation.
|
// Environment variable loading and validation.
|
||||||
|
|
||||||
|
export function loadConfig() {
|
||||||
|
const key = process.env.OPENAI_API_KEY;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('Configuration error: OPENAI_API_KEY is missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseNum = (name, raw, fallback) => {
|
||||||
|
if (raw === undefined || raw === '') return fallback;
|
||||||
|
const n = Number(raw);
|
||||||
|
if (isNaN(n) || !Number.isFinite(n) || n < 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Configuration error: ${name} must be a positive number, got "${raw}".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseBool = (name, raw, fallback) => {
|
||||||
|
if (raw === undefined || raw === '') return fallback;
|
||||||
|
if (raw === 'true') return true;
|
||||||
|
if (raw === 'false') return false;
|
||||||
|
throw new Error(
|
||||||
|
`Configuration error: ${name} must be true or false, got "${raw}".`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
openaiApiKey: key,
|
||||||
|
openaiModel: process.env.OPENAI_MODEL || 'gpt-5.1',
|
||||||
|
temperature: parseNum('OPENAI_TEMPERATURE', process.env.OPENAI_TEMPERATURE, 0.2),
|
||||||
|
maxOutputTokens: parseNum('OPENAI_MAX_OUTPUT_TOKENS', process.env.OPENAI_MAX_OUTPUT_TOKENS, 2000),
|
||||||
|
logLevel: process.env.CHATGPT_MCP_LOG_LEVEL || 'info',
|
||||||
|
enableFileContext: parseBool('CHATGPT_MCP_ENABLE_FILE_CONTEXT', process.env.CHATGPT_MCP_ENABLE_FILE_CONTEXT, false),
|
||||||
|
contextDir: process.env.CHATGPT_MCP_CONTEXT_DIR || './context',
|
||||||
|
maxInputChars: parseNum('CHATGPT_MCP_MAX_INPUT_CHARS', process.env.CHATGPT_MCP_MAX_INPUT_CHARS, 30000),
|
||||||
|
maxFileChars: parseNum('CHATGPT_MCP_MAX_FILE_CHARS', process.env.CHATGPT_MCP_MAX_FILE_CHARS, 12000),
|
||||||
|
maxFiles: parseNum('CHATGPT_MCP_MAX_FILES', process.env.CHATGPT_MCP_MAX_FILES, 5),
|
||||||
|
maxLogChars: parseNum('CHATGPT_MCP_MAX_LOG_CHARS', process.env.CHATGPT_MCP_MAX_LOG_CHARS, 10000),
|
||||||
|
redactSecrets: parseBool('CHATGPT_MCP_REDACT_SECRETS', process.env.CHATGPT_MCP_REDACT_SECRETS, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { loadConfig } from '../../src/config/env.js';
|
||||||
|
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
function setEnv(partial) {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (key.startsWith('OPENAI_') || key.startsWith('CHATGPT_MCP_')) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.assign(process.env, partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setEnv({ OPENAI_API_KEY: 'test-key' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (key.startsWith('OPENAI_') || key.startsWith('CHATGPT_MCP_')) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.assign(process.env, originalEnv);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadConfig', () => {
|
||||||
|
it('throws when OPENAI_API_KEY is missing', () => {
|
||||||
|
delete process.env.OPENAI_API_KEY;
|
||||||
|
expect(() => loadConfig()).toThrow(
|
||||||
|
'Configuration error: OPENAI_API_KEY is missing.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns defaults when no optional vars are set', () => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
expect(cfg.openaiApiKey).toBe('test-key');
|
||||||
|
expect(cfg.openaiModel).toBe('gpt-5.1');
|
||||||
|
expect(cfg.temperature).toBe(0.2);
|
||||||
|
expect(cfg.maxOutputTokens).toBe(2000);
|
||||||
|
expect(cfg.logLevel).toBe('info');
|
||||||
|
expect(cfg.enableFileContext).toBe(false);
|
||||||
|
expect(cfg.contextDir).toBe('./context');
|
||||||
|
expect(cfg.maxInputChars).toBe(30000);
|
||||||
|
expect(cfg.maxFileChars).toBe(12000);
|
||||||
|
expect(cfg.maxFiles).toBe(5);
|
||||||
|
expect(cfg.maxLogChars).toBe(10000);
|
||||||
|
expect(cfg.redactSecrets).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom string values', () => {
|
||||||
|
setEnv({
|
||||||
|
OPENAI_API_KEY: 'test-key',
|
||||||
|
OPENAI_MODEL: 'gpt-4',
|
||||||
|
CHATGPT_MCP_LOG_LEVEL: 'debug',
|
||||||
|
CHATGPT_MCP_CONTEXT_DIR: './custom',
|
||||||
|
});
|
||||||
|
const cfg = loadConfig();
|
||||||
|
expect(cfg.openaiModel).toBe('gpt-4');
|
||||||
|
expect(cfg.logLevel).toBe('debug');
|
||||||
|
expect(cfg.contextDir).toBe('./custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom numeric values', () => {
|
||||||
|
setEnv({
|
||||||
|
OPENAI_API_KEY: 'test-key',
|
||||||
|
OPENAI_TEMPERATURE: '0.5',
|
||||||
|
OPENAI_MAX_OUTPUT_TOKENS: '4096',
|
||||||
|
CHATGPT_MCP_MAX_INPUT_CHARS: '15000',
|
||||||
|
CHATGPT_MCP_MAX_FILE_CHARS: '8000',
|
||||||
|
CHATGPT_MCP_MAX_FILES: '3',
|
||||||
|
CHATGPT_MCP_MAX_LOG_CHARS: '5000',
|
||||||
|
});
|
||||||
|
const cfg = loadConfig();
|
||||||
|
expect(cfg.temperature).toBe(0.5);
|
||||||
|
expect(cfg.maxOutputTokens).toBe(4096);
|
||||||
|
expect(cfg.maxInputChars).toBe(15000);
|
||||||
|
expect(cfg.maxFileChars).toBe(8000);
|
||||||
|
expect(cfg.maxFiles).toBe(3);
|
||||||
|
expect(cfg.maxLogChars).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies custom boolean values', () => {
|
||||||
|
setEnv({
|
||||||
|
OPENAI_API_KEY: 'test-key',
|
||||||
|
CHATGPT_MCP_ENABLE_FILE_CONTEXT: 'true',
|
||||||
|
CHATGPT_MCP_REDACT_SECRETS: 'false',
|
||||||
|
});
|
||||||
|
const cfg = loadConfig();
|
||||||
|
expect(cfg.enableFileContext).toBe(true);
|
||||||
|
expect(cfg.redactSecrets).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on invalid numeric values', () => {
|
||||||
|
setEnv({
|
||||||
|
OPENAI_API_KEY: 'test-key',
|
||||||
|
CHATGPT_MCP_MAX_INPUT_CHARS: 'abc',
|
||||||
|
});
|
||||||
|
expect(() => loadConfig()).toThrow(
|
||||||
|
'Configuration error: CHATGPT_MCP_MAX_INPUT_CHARS must be a positive number, got "abc".'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on invalid boolean values', () => {
|
||||||
|
setEnv({
|
||||||
|
OPENAI_API_KEY: 'test-key',
|
||||||
|
CHATGPT_MCP_ENABLE_FILE_CONTEXT: 'yes',
|
||||||
|
});
|
||||||
|
expect(() => loadConfig()).toThrow(
|
||||||
|
'Configuration error: CHATGPT_MCP_ENABLE_FILE_CONTEXT must be true or false, got "yes".'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on negative numeric values', () => {
|
||||||
|
setEnv({
|
||||||
|
OPENAI_API_KEY: 'test-key',
|
||||||
|
CHATGPT_MCP_MAX_FILES: '-1',
|
||||||
|
});
|
||||||
|
expect(() => loadConfig()).toThrow(
|
||||||
|
'Configuration error: CHATGPT_MCP_MAX_FILES must be a positive number, got "-1".'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on NaN numeric values', () => {
|
||||||
|
setEnv({
|
||||||
|
OPENAI_API_KEY: 'test-key',
|
||||||
|
CHATGPT_MCP_MAX_FILES: 'NaN',
|
||||||
|
});
|
||||||
|
expect(() => loadConfig()).toThrow(
|
||||||
|
'Configuration error: CHATGPT_MCP_MAX_FILES must be a positive number, got "NaN".'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on Infinity numeric values', () => {
|
||||||
|
setEnv({
|
||||||
|
OPENAI_API_KEY: 'test-key',
|
||||||
|
CHATGPT_MCP_MAX_FILES: 'Infinity',
|
||||||
|
});
|
||||||
|
expect(() => loadConfig()).toThrow(
|
||||||
|
'Configuration error: CHATGPT_MCP_MAX_FILES must be a positive number, got "Infinity".'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user