Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`. 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:
Expand Down
27 changes: 22 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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

Expand Down Expand Up @@ -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`.
Expand All @@ -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
Expand Down
17 changes: 12 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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') {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Comment thread
jdrhyne marked this conversation as resolved.

// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
Expand Down
3 changes: 3 additions & 0 deletions src/loadEnv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import dotenv from 'dotenv';

dotenv.config();
11 changes: 9 additions & 2 deletions src/utils/Environment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { assertHttpTransportSecurity } from './HttpSecurity.js';

// Environment variable schema
const environmentSchema = z.object({
Expand All @@ -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(),
Expand All @@ -45,7 +50,9 @@ export type ParsedEnvironment = z.infer<typeof environmentSchema>;
*/
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
Expand Down
61 changes: 61 additions & 0 deletions src/utils/HttpSecurity.ts
Original file line number Diff line number Diff line change
@@ -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');
Comment thread
jdrhyne marked this conversation as resolved.
}

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();
};
}
54 changes: 52 additions & 2 deletions test/environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
});
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading