diff --git a/.env.example b/.env.example index f65ce34..556d56f 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ # Required variables DOCUMENT_ENGINE_BASE_URL=https://your-document-engine-instance.com -DOCUMENT_ENGINE_AUTH_TOKEN=your-auth-token +DOCUMENT_ENGINE_API_AUTH_TOKEN=your-auth-token # OpenAI API Key for LLM access OPENAI_API_KEY=your-openai-api-key @@ -18,7 +18,7 @@ OPENAI_API_KEY=your-openai-api-key # MCP Transport configuration # MCP_TRANSPORT=stdio # PORT=5100 -# MCP_HOST=localhost +# MCP_HOST=127.0.0.1 # Dashboard configuration # DASHBOARD_USERNAME=admin diff --git a/README.md b/README.md index 3d0f725..b610d61 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,25 @@ Add this to your Claude Desktop config (Settings → Developer → Edit Config): 3. Switch back to Claude Desktop and ask questions like: *"List my documents and extract text from the contract"* +## HTTP Transport Security + +The optional HTTP transport binds to `127.0.0.1` by default. A non-loopback `MCP_HOST` requires +`MCP_HTTP_AUTH_TOKEN`; the server refuses to start without a nonempty token. Clients must send the +token on every `/mcp` request as `Authorization: Bearer `. If a token is set for a loopback +HTTP server, `/mcp` requires it there too. This token does not protect `/health` or `/dashboard`; +dashboard access uses `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD`. + +```bash +# Local-only HTTP transport; no MCP bearer token required. +MCP_TRANSPORT=http npx @nutrient-sdk/document-engine-mcp-server + +# Network-accessible HTTP transport; bearer authentication is required. +MCP_TRANSPORT=http MCP_HOST=0.0.0.0 MCP_HTTP_AUTH_TOKEN=replace-with-a-secret \ + npx @nutrient-sdk/document-engine-mcp-server +``` + +The default stdio transport is unchanged and never requires `MCP_HTTP_AUTH_TOKEN`. + ## Features This MCP server provides document processing tools in these areas: diff --git a/docs/configuration.md b/docs/configuration.md index bc4702e..7a8ad5a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,7 +16,8 @@ This guide covers configuration options, environment variables, transport modes, | Variable | Description | Default | Example | |----------------------|-------------------------------------------------------------------------------------------------|-------------|------------| | `MCP_TRANSPORT` | Transport type - "stdio" or "http" | `stdio` | `http` | -| `MCP_HOST` | The host as IP address for the Dashboard and the MCP server and Dashboard | `localhost` | `0.0.0.0` | +| `MCP_HOST` | Bind host for the Dashboard and HTTP MCP server | `127.0.0.1` | `0.0.0.0` | +| `MCP_HTTP_AUTH_TOKEN` | Bearer token for `/mcp`; required for non-loopback HTTP binding | `undefined` | `your-secure-token-here` | | `PORT` | HTTP server port (HTTP transport only) | `5100` | `8080` | | `MAX_RETRIES` | Number of API request retries | `3` | `5` | | `CONNECTION_TIMEOUT` | Request timeout in milliseconds | `30000` | `60000` | @@ -27,10 +28,12 @@ This guide covers configuration options, environment variables, transport modes, | `DASHBOARD_PASSWORD` | Dashboard authentication password (required to enable dashboard) | `undefined` | `password` | > MCP_HOST information -> Setting host to `127.0.0.1` (localhost) ensure that the Dashboard and the MCP server is only accessible locally -> Setting host to `0.0.0.0` make it so that the Dashboard and the MCP server is accessible from any IP address assigned to the machine +> Setting the host to `localhost`, an address in `127.0.0.0/8`, or `::1` keeps the server on a +> loopback interface. Setting it to `0.0.0.0`, `::`, or another non-loopback host can make the +> server accessible over the network. With `MCP_TRANSPORT=http`, any non-loopback host requires a +> nonempty `MCP_HTTP_AUTH_TOKEN`; the server refuses to start without it. > -> Note: Host header validation (DNS rebinding protection) is only applied when `MCP_HOST` is a specific host (for example `localhost`, `127.0.0.1`, or `::1`). When binding to all interfaces (`0.0.0.0` or `::`), host validation is not applied; use other protections (auth, firewall, reverse proxy) if exposing the server. +> Note: Host header validation (DNS rebinding protection) is only applied when `MCP_HOST` is a specific host (for example `localhost`, `127.0.0.1`, or `::1`). When binding to all interfaces (`0.0.0.0` or `::`), host validation is not applied; use a firewall, TLS-terminating reverse proxy, and appropriate network controls in addition to the required bearer token. ## Transport Modes @@ -77,6 +80,8 @@ MCP_TRANSPORT=http PORT=5100 npx @nutrient-sdk/document-engine-mcp-server **Characteristics:** - MCP communication via HTTP at `/mcp` endpoint. +- Local-only bind to `127.0.0.1` by default. +- Bearer authentication on `/mcp` whenever `MCP_HTTP_AUTH_TOKEN` is configured, and always for a non-loopback bind. - UUID-based session management. - Built-in dashboard at `/dashboard` when credentials are provided. - Health monitoring at `/health`. @@ -87,10 +92,22 @@ MCP_TRANSPORT=http PORT=5100 npx @nutrient-sdk/document-engine-mcp-server # Basic HTTP setup MCP_TRANSPORT=http npx @nutrient-sdk/document-engine-mcp-server -# Custom port and credentials +# Custom port and dashboard credentials (still local-only) MCP_TRANSPORT=http PORT=8080 DASHBOARD_USERNAME=admin DASHBOARD_PASSWORD=secure npx @nutrient-sdk/document-engine-mcp-server + +# Network-accessible HTTP setup (MCP bearer token is required) +MCP_TRANSPORT=http MCP_HOST=0.0.0.0 MCP_HTTP_AUTH_TOKEN=replace-with-a-secret npx @nutrient-sdk/document-engine-mcp-server + +# Every MCP request must present the configured token +curl -H "Authorization: Bearer replace-with-a-secret" http://localhost:5100/mcp ``` +`MCP_HTTP_AUTH_TOKEN` protects POST, GET, and DELETE requests to `/mcp`. It does not protect +`/health` or `/dashboard`; configure dashboard Basic authentication separately with +`DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD`. When the HTTP server uses a loopback host, the token +is optional, but setting it still enables bearer authentication for `/mcp`. The stdio transport +does not require this variable, even if `MCP_HOST` is non-loopback for an optional dashboard. + ## Dashboard Interface The web dashboard is available in both transport modes at `http://localhost:5100/dashboard` when both `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD` environment variables are provided diff --git a/src/index.ts b/src/index.ts index f9f2e48..badc7eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,11 @@ #!/usr/bin/env node +import './loadEnv.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js'; -import dotenv from 'dotenv'; import express from 'express'; import { randomUUID } from 'node:crypto'; import { isIP } from 'node:net'; @@ -17,8 +17,7 @@ import { healthCheck } from './tools/healthCheck.js'; import { createDashboardRouter } from './dashboard/index.js'; import { DocumentEngineClient } from './api/Client.js'; import { getVersion } from './version.js'; - -dotenv.config(); +import { createMcpHttpAuthMiddleware } from './utils/HttpSecurity.js'; // Validate environment variables at startup (skip in test environment) if (process.env.NODE_ENV !== 'test') { @@ -109,8 +108,16 @@ function configureMCPServerTools(server: McpServer): void { } } -function createExpressApp(enableDashboard: boolean = false): express.Application { +function createExpressApp( + enableDashboard: boolean = false, + mcpHttpAuthToken?: string +): express.Application { const app = express(); + + if (mcpHttpAuthToken) { + app.all('/mcp', createMcpHttpAuthMiddleware(mcpHttpAuthToken)); + } + app.use(express.json()); const env = getEnvironment(); @@ -200,7 +207,7 @@ async function startStdioServer() { async function startHttpServer() { const env = getEnvironment(); const dashboardEnabled = !!(env.DASHBOARD_USERNAME && env.DASHBOARD_PASSWORD); - const app = createExpressApp(dashboardEnabled); + const app = createExpressApp(dashboardEnabled, env.MCP_HTTP_AUTH_TOKEN); // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; diff --git a/src/loadEnv.ts b/src/loadEnv.ts new file mode 100644 index 0000000..d274743 --- /dev/null +++ b/src/loadEnv.ts @@ -0,0 +1,3 @@ +import dotenv from 'dotenv'; + +dotenv.config(); diff --git a/src/utils/Environment.ts b/src/utils/Environment.ts index 9a3f762..1f4c501 100644 --- a/src/utils/Environment.ts +++ b/src/utils/Environment.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { assertHttpTransportSecurity } from './HttpSecurity.js'; // Environment variable schema const environmentSchema = z.object({ @@ -24,7 +25,11 @@ const environmentSchema = z.object({ // MCP Transport configuration MCP_TRANSPORT: z.enum(['stdio', 'http']).default('stdio'), PORT: z.coerce.number().int().min(1).max(65535).default(5100), - MCP_HOST: z.string().default('localhost'), + MCP_HOST: z.string().default('127.0.0.1'), + MCP_HTTP_AUTH_TOKEN: z + .string() + .optional() + .transform(token => token?.trim() || undefined), // Dashboard configuration (optional - only enabled when both username and password are provided) DASHBOARD_USERNAME: z.string().optional(), @@ -45,7 +50,9 @@ export type ParsedEnvironment = z.infer; */ export function validateEnvironment(): ParsedEnvironment { try { - return environmentSchema.parse(process.env); + const environment = environmentSchema.parse(process.env); + assertHttpTransportSecurity(environment); + return environment; } catch (error) { if (error instanceof z.ZodError) { const errorMessages = error.errors diff --git a/src/utils/HttpSecurity.ts b/src/utils/HttpSecurity.ts new file mode 100644 index 0000000..ccd8b4f --- /dev/null +++ b/src/utils/HttpSecurity.ts @@ -0,0 +1,61 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import type { RequestHandler } from 'express'; + +interface HttpTransportSecurityConfig { + MCP_TRANSPORT: 'stdio' | 'http'; + MCP_HOST: string; + MCP_HTTP_AUTH_TOKEN?: string; +} + +export function isLoopbackHost(host: string): boolean { + const normalizedHost = host.trim().toLowerCase(); + + if (normalizedHost === 'localhost' || normalizedHost === '::1') { + return true; + } + + const ipv4Octets = normalizedHost.split('.'); + if (ipv4Octets.length !== 4 || ipv4Octets.some(octet => !/^\d{1,3}$/.test(octet))) { + return false; + } + + const numericOctets = ipv4Octets.map(Number); + return numericOctets[0] === 127 && numericOctets.every(octet => octet >= 0 && octet <= 255); +} + +export function assertHttpTransportSecurity(config: HttpTransportSecurityConfig): void { + if (config.MCP_TRANSPORT !== 'http' || isLoopbackHost(config.MCP_HOST)) { + return; + } + + if (!config.MCP_HTTP_AUTH_TOKEN || config.MCP_HTTP_AUTH_TOKEN.trim().length === 0) { + throw new Error( + 'MCP_HTTP_AUTH_TOKEN is required when MCP_TRANSPORT=http binds to a non-loopback MCP_HOST' + ); + } +} + +function hashToken(token: string): Buffer { + return createHash('sha256').update(token, 'utf8').digest(); +} + +export function createMcpHttpAuthMiddleware(expectedToken: string): RequestHandler { + if (expectedToken.length === 0) { + throw new Error('MCP_HTTP_AUTH_TOKEN must not be empty'); + } + + const expectedDigest = hashToken(expectedToken); + + return (req, res, next) => { + const authorization = req.get('authorization'); + const match = authorization?.match(/^Bearer (.+)$/i); + const presentedDigest = hashToken(match?.[1] ?? ''); + + if (!match || !timingSafeEqual(presentedDigest, expectedDigest)) { + res.status(401).send('Unauthorized'); + return; + } + + next(); + }; +} diff --git a/test/environment.test.ts b/test/environment.test.ts index 86e4c40..01a235f 100644 --- a/test/environment.test.ts +++ b/test/environment.test.ts @@ -37,7 +37,8 @@ describe('Environment Validation', () => { LOG_LEVEL: 'info', MCP_TRANSPORT: 'stdio', PORT: 5100, - MCP_HOST: 'localhost', + MCP_HOST: '127.0.0.1', + MCP_HTTP_AUTH_TOKEN: undefined, DOCUMENT_ENGINE_POLL_MAX_RETRIES: 30, DOCUMENT_ENGINE_POLL_RETRY_DELAY: 2000, }); @@ -68,7 +69,8 @@ describe('Environment Validation', () => { LOG_LEVEL: 'debug', MCP_TRANSPORT: 'stdio', PORT: 5100, - MCP_HOST: 'localhost', + MCP_HOST: '127.0.0.1', + MCP_HTTP_AUTH_TOKEN: undefined, DOCUMENT_ENGINE_POLL_MAX_RETRIES: 30, DOCUMENT_ENGINE_POLL_RETRY_DELAY: 2000, }); @@ -82,6 +84,7 @@ describe('Environment Validation', () => { expect(result.DOCUMENT_ENGINE_BASE_URL).toBe('http://localhost:5000'); expect(result.DOCUMENT_ENGINE_API_AUTH_TOKEN).toBe('secret'); expect(result.PORT).toBe(5100); + expect(result.MCP_HOST).toBe('127.0.0.1'); }); it('should throw error for invalid URL', () => { @@ -145,6 +148,53 @@ describe('Environment Validation', () => { expect(result.LOG_LEVEL).toBe(level); } }); + + it('should require MCP_HTTP_AUTH_TOKEN for non-loopback HTTP binding', () => { + process.env = { + MCP_TRANSPORT: 'http', + MCP_HOST: '0.0.0.0', + }; + + expect(() => validateEnvironment()).toThrow('MCP_HTTP_AUTH_TOKEN is required'); + }); + + it('should allow non-loopback HTTP binding with MCP_HTTP_AUTH_TOKEN', () => { + process.env = { + MCP_TRANSPORT: 'http', + MCP_HOST: '0.0.0.0', + MCP_HTTP_AUTH_TOKEN: 'http-secret', + }; + + expect(validateEnvironment().MCP_HTTP_AUTH_TOKEN).toBe('http-secret'); + }); + + it('should trim MCP_HTTP_AUTH_TOKEN', () => { + process.env = { + MCP_TRANSPORT: 'http', + MCP_HOST: '0.0.0.0', + MCP_HTTP_AUTH_TOKEN: ' http-secret ', + }; + + expect(validateEnvironment().MCP_HTTP_AUTH_TOKEN).toBe('http-secret'); + }); + + it('should treat a whitespace-only MCP_HTTP_AUTH_TOKEN as absent', () => { + process.env = { + MCP_TRANSPORT: 'stdio', + MCP_HTTP_AUTH_TOKEN: ' ', + }; + + expect(validateEnvironment().MCP_HTTP_AUTH_TOKEN).toBeUndefined(); + }); + + it('should not require MCP_HTTP_AUTH_TOKEN for stdio transport', () => { + process.env = { + MCP_TRANSPORT: 'stdio', + MCP_HOST: '0.0.0.0', + }; + + expect(validateEnvironment().MCP_HTTP_AUTH_TOKEN).toBeUndefined(); + }); }); describe('getEnvironment', () => { diff --git a/test/httpSecurity.test.ts b/test/httpSecurity.test.ts new file mode 100644 index 0000000..ad24a03 --- /dev/null +++ b/test/httpSecurity.test.ts @@ -0,0 +1,156 @@ +import express from 'express'; +import request from 'supertest'; +import { describe, expect, it } from 'vitest'; +import { + assertHttpTransportSecurity, + createMcpHttpAuthMiddleware, + isLoopbackHost, +} from '../src/utils/HttpSecurity.js'; +import { validateEnvironment } from '../src/utils/Environment.js'; + +describe('HTTP transport security', () => { + describe('isLoopbackHost', () => { + it.each(['localhost', 'LOCALHOST', '127.0.0.1', '127.255.255.254', '::1'])( + 'recognizes %s as loopback', + host => { + expect(isLoopbackHost(host)).toBe(true); + } + ); + + it.each(['0.0.0.0', '192.168.1.2', '126.255.255.255', '128.0.0.1', '::'])( + 'recognizes %s as non-loopback', + host => { + expect(isLoopbackHost(host)).toBe(false); + } + ); + }); + + describe('assertHttpTransportSecurity', () => { + it.each([undefined, '', ' '])( + 'rejects non-loopback HTTP binding with token %j', + MCP_HTTP_AUTH_TOKEN => { + expect(() => + assertHttpTransportSecurity({ + MCP_TRANSPORT: 'http', + MCP_HOST: '0.0.0.0', + MCP_HTTP_AUTH_TOKEN, + }) + ).toThrow('MCP_HTTP_AUTH_TOKEN'); + } + ); + + it('allows non-loopback HTTP binding with a token', () => { + expect(() => + assertHttpTransportSecurity({ + MCP_TRANSPORT: 'http', + MCP_HOST: '0.0.0.0', + MCP_HTTP_AUTH_TOKEN: 'http-secret', + }) + ).not.toThrow(); + }); + + it.each(['localhost', '127.42.0.1', '::1'])( + 'allows loopback HTTP binding without a token for %s', + MCP_HOST => { + expect(() => + assertHttpTransportSecurity({ MCP_TRANSPORT: 'http', MCP_HOST }) + ).not.toThrow(); + } + ); + + it('does not apply the HTTP token requirement to stdio', () => { + expect(() => + assertHttpTransportSecurity({ MCP_TRANSPORT: 'stdio', MCP_HOST: '0.0.0.0' }) + ).not.toThrow(); + }); + }); + + describe('createMcpHttpAuthMiddleware', () => { + function createApp(expectedToken = 'http-secret') { + const app = express(); + app.all('/mcp', createMcpHttpAuthMiddleware(expectedToken)); + app.all('/mcp', (_req, res) => res.sendStatus(204)); + app.get('/health', (_req, res) => res.sendStatus(204)); + return app; + } + + it.each([ + ['missing', undefined], + ['malformed', 'Basic http-secret'], + ['empty bearer token', 'Bearer '], + ['wrong token', 'Bearer wrong-secret'], + ])('returns 401 for a %s Authorization header', async (_description, authorization) => { + const testRequest = request(createApp()).post('/mcp'); + if (authorization) { + testRequest.set('Authorization', authorization); + } + + await testRequest.expect(401); + }); + + it('allows the configured Bearer token', async () => { + await request(createApp()) + .post('/mcp') + .set('Authorization', 'Bearer http-secret') + .expect(204); + }); + + it('allows the trimmed configured Bearer token', async () => { + const originalEnv = process.env; + process.env = { + MCP_TRANSPORT: 'http', + MCP_HOST: '0.0.0.0', + MCP_HTTP_AUTH_TOKEN: 'secret ', + }; + + try { + const expectedToken = validateEnvironment().MCP_HTTP_AUTH_TOKEN; + expect(expectedToken).toBe('secret'); + if (!expectedToken) { + throw new Error('Expected MCP_HTTP_AUTH_TOKEN to be configured'); + } + + await request(createApp(expectedToken)) + .post('/mcp') + .set('Authorization', 'Bearer secret') + .expect(204); + } finally { + process.env = originalEnv; + } + }); + + it.each(['get', 'post', 'delete'] as const)('protects %s /mcp', async method => { + await request(createApp())[method]('/mcp').expect(401); + await request(createApp()) + [method]('/mcp') + .set('Authorization', 'Bearer http-secret') + .expect(204); + }); + + it('does not protect the health endpoint', async () => { + await request(createApp()).get('/health').expect(204); + }); + + it('authenticates /mcp before parsing JSON request bodies', async () => { + const app = express(); + app.all('/mcp', createMcpHttpAuthMiddleware('http-secret')); + app.use(express.json()); + app.post('/mcp', (_req, res) => res.sendStatus(204)); + + const malformedJson = '{"invalid"'; + + await request(app) + .post('/mcp') + .set('Content-Type', 'application/json') + .send(malformedJson) + .expect(401); + + await request(app) + .post('/mcp') + .set('Content-Type', 'application/json') + .set('Authorization', 'Bearer http-secret') + .send(malformedJson) + .expect(400); + }); + }); +}); diff --git a/test/integration.test.ts b/test/integration.test.ts index a961c9f..3487417 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -46,7 +46,7 @@ dotenv.config(); * * Example .env: * DOCUMENT_ENGINE_BASE_URL=https://your-instance.nutrient.io - * DOCUMENT_ENGINE_AUTH_TOKEN=your-auth-token + * DOCUMENT_ENGINE_API_AUTH_TOKEN=your-auth-token * availableDocumentId_ID=doc_123456789 * * Run with: pnpm test:integration @@ -54,14 +54,14 @@ dotenv.config(); // Skip integration tests if environment variables are not set const skipIntegrationTests = - !process.env.DOCUMENT_ENGINE_BASE_URL || !process.env.DOCUMENT_ENGINE_AUTH_TOKEN; + !process.env.DOCUMENT_ENGINE_BASE_URL || !process.env.DOCUMENT_ENGINE_API_AUTH_TOKEN; // Set NODE_ENV to test to skip environment validation if (!skipIntegrationTests) { process.env.NODE_ENV = 'test'; } -describe('Integration Tests - Document Engine API', () => { +describe.skipIf(skipIntegrationTests)('Integration Tests - Document Engine API', () => { let client: DocumentEngineClient; let availableDocumentId: string; @@ -576,7 +576,7 @@ if (skipIntegrationTests) { console.log(''); console.log('Option 2 - Using environment variables:'); console.log('1. Set DOCUMENT_ENGINE_BASE_URL environment variable'); - console.log('2. Set DOCUMENT_ENGINE_AUTH_TOKEN environment variable'); + console.log('2. Set DOCUMENT_ENGINE_API_AUTH_TOKEN environment variable'); console.log('3. Optionally set availableDocumentId_ID for a specific document'); console.log('4. Run: pnpm test:integration'); } diff --git a/test/integration/layers.test.ts b/test/integration/layers.test.ts index 490c61d..cfa7d87 100644 --- a/test/integration/layers.test.ts +++ b/test/integration/layers.test.ts @@ -33,14 +33,14 @@ dotenv.config(); // Skip integration tests if environment variables are not set const skipIntegrationTests = - !process.env.DOCUMENT_ENGINE_BASE_URL || !process.env.DOCUMENT_ENGINE_AUTH_TOKEN; + !process.env.DOCUMENT_ENGINE_BASE_URL || !process.env.DOCUMENT_ENGINE_API_AUTH_TOKEN; // Set NODE_ENV to test to skip environment validation if (!skipIntegrationTests) { process.env.NODE_ENV = 'test'; } -describe('Layer Integration Tests - Document Engine API', () => { +describe.skipIf(skipIntegrationTests)('Layer Integration Tests - Document Engine API', () => { let client: DocumentEngineClient; let testDocumentId: string; let testLayerId: string; @@ -505,7 +505,7 @@ if (skipIntegrationTests) { console.log(''); console.log('1. Set up your Document Engine instance with layer support'); console.log('2. Set DOCUMENT_ENGINE_BASE_URL environment variable'); - console.log('3. Set DOCUMENT_ENGINE_AUTH_TOKEN environment variable'); + console.log('3. Set DOCUMENT_ENGINE_API_AUTH_TOKEN environment variable'); console.log('4. Run: pnpm test test/integration/layers.test.ts'); console.log(''); console.log('These tests verify that layer functionality works correctly across all MCP tools.'); diff --git a/test/loadEnv.test.ts b/test/loadEnv.test.ts new file mode 100644 index 0000000..be3394d --- /dev/null +++ b/test/loadEnv.test.ts @@ -0,0 +1,64 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({ + McpServer: class { + tool() {} + async connect() {} + async close() {} + }, +})); + +vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ + StdioServerTransport: class {}, +})); + +vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({ + StreamableHTTPServerTransport: class {}, +})); + +vi.mock('../src/api/ClientFactory.js', () => ({ + getDocumentEngineClient: vi.fn().mockResolvedValue({ + get: vi.fn().mockResolvedValue({}), + }), +})); + +describe('entry module environment loading', () => { + const originalCwd = process.cwd(); + const originalEnv = { ...process.env }; + const originalSigintListeners = process.listeners('SIGINT'); + let temporaryDirectory: string | undefined; + + afterEach(async () => { + process.chdir(originalCwd); + process.env = { ...originalEnv }; + vi.resetModules(); + for (const listener of process.listeners('SIGINT')) { + if (!originalSigintListeners.includes(listener)) { + process.removeListener('SIGINT', listener); + } + } + + if (temporaryDirectory) { + await rm(temporaryDirectory, { recursive: true, force: true }); + temporaryDirectory = undefined; + } + }); + + it('loads .env before application modules memoize the environment', async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), 'document-engine-mcp-env-')); + await writeFile(join(temporaryDirectory, '.env'), 'MCP_HTTP_AUTH_TOKEN=from-dotenv\n'); + + process.chdir(temporaryDirectory); + process.env = { ...originalEnv, NODE_ENV: 'test', MCP_TRANSPORT: 'stdio' }; + delete process.env.MCP_HTTP_AUTH_TOKEN; + vi.resetModules(); + + await import('../src/index.js'); + const { getEnvironment } = await import('../src/utils/Environment.js'); + + expect(getEnvironment().MCP_HTTP_AUTH_TOKEN).toBe('from-dotenv'); + }); +});