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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

### Added

* HTTP (Streamable HTTP) transport mode via `MCP_TRANSPORT=http` environment variable, enabling clients with long per-call timeouts (e.g. Google ADK) to use the server without subprocess management.
* `GET /health` endpoint returns `{"status":"ok"}` for container liveness probes.
* `PORT`, `MCP_HTTP_HOST`, `MCP_HTTP_ALLOWED_HOSTS`, and `MCP_HTTP_ALLOWED_ORIGINS` env vars for HTTP transport configuration.
* DNS-rebinding protection (`enableDnsRebindingProtection`) enabled by default in HTTP mode.
* Per-request stateless isolation in HTTP mode — each `POST /mcp` gets a fresh `McpServer` instance so concurrent callers never share state.

## [1.2.0] - 2026-07-02

### Added
Expand Down
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,67 @@ To use Docker from your MCP client config (e.g., Cursor or Claude Desktop), repl
}
```

## 🌐 Running over HTTP (Streamable HTTP transport)

By default the server communicates over **stdio** — the mode used by Claude Desktop, Cursor, and most MCP clients. An alternative **HTTP** transport is available for clients that need longer per-call timeouts (e.g. Google ADK, which caps stdio subprocesses at ~5 s) or that cannot manage a child process at all.

### When to use HTTP vs. stdio

| | stdio | HTTP |
|---|---|---|
| Claude Desktop / Cursor | ✅ default | not needed |
| Google ADK / sre-ops-bot | ❌ 5 s timeout | ✅ use HTTP |
| Kubernetes sidecar | possible | ✅ preferred |

### Env vars

| Variable | Default | Purpose |
|---|---|---|
| `MCP_TRANSPORT` | `stdio` | Set to `http` to enable HTTP mode; any other value exits with an error |
| `PORT` | `8080` | HTTP listen port |
| `MCP_HTTP_HOST` | `127.0.0.1` | Bind address. Use `0.0.0.0` for K8s pod liveness probes (see security note below) |
| `MCP_HTTP_ALLOWED_HOSTS` | `127.0.0.1` | Comma-separated list of allowed `Host` header values (DNS-rebinding protection) |
| `MCP_HTTP_ALLOWED_ORIGINS` | *(none)* | Comma-separated list of allowed `Origin` header values; omit for non-browser clients |

### Security note

HTTP mode has **no built-in authentication**. Use it only on a trusted network or as a sidecar running on localhost. If you set `MCP_HTTP_HOST=0.0.0.0` to allow Kubernetes httpGet liveness probes, set `MCP_HTTP_ALLOWED_HOSTS` to the pod's expected hostname to preserve DNS-rebinding protection.

### Running with Docker (HTTP mode)

```bash
docker run --rm -p 8080:8080 \
-e MCP_TRANSPORT=http \
-e MCP_HTTP_HOST=0.0.0.0 \
-e HUMAN_CYBERFRAUD_API_TOKEN=<value> \
-e HUMAN_CODE_DEFENDER_API_TOKEN=<value> \
us-docker.pkg.dev/hmn-registry-public/containers/human-mcp-server:latest
```

Verify it's up:

```bash
curl -s http://localhost:8080/health
# {"status":"ok"}
```

### MCP client config (HTTP)

```json
{
"mcpServers": {
"human-security": {
"type": "http",
"url": "http://127.0.0.1:8080/mcp",
"env": {
"HUMAN_CYBERFRAUD_API_TOKEN": "your-cyberfraud-token",
"HUMAN_CODE_DEFENDER_API_TOKEN": "your-code-defender-token"
}
}
}
}
```

### Optional Configuration
- **`HUMAN_API_HOST`**: Use a different API endpoint (default: `api.humansecurity.com`)
- **`HUMAN_API_VERSION`**: Specify API version (default: `v1`)
Expand Down
216 changes: 202 additions & 14 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,176 @@
import process from 'process';
import http from 'node:http';
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 type { StreamableHTTPServerTransportOptions } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { registerTools } from '../tools';
import { HttpClient } from '../utils/httpClient';
import { CyberfraudService } from '../services/cyberfraudService';
import { CodeDefenderService } from '../services/codeDefenderService';
import { MCP_VERSION } from '../utils/constants';

type Services = {
cyberfraudService?: CyberfraudService;
codeDefenderService?: CodeDefenderService;
};

const BODY_SIZE_LIMIT = 1024 * 1024; // 1 MB

async function readBody(req: http.IncomingMessage): Promise<{ body: string; tooLarge: boolean }> {
const chunks: Buffer[] = [];
let size = 0;

for await (const chunk of req) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as string);
size += buf.length;
if (size > BODY_SIZE_LIMIT) {
return { body: '', tooLarge: true };
}
chunks.push(buf);
}

return { body: Buffer.concat(chunks).toString('utf8'), tooLarge: false };
}

function jsonRpcError(res: http.ServerResponse, statusCode: number, code: number, message: string): void {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null }));
}

function startHttpServer(services: Services): http.Server {
const port = Number.parseInt(process.env.PORT ?? '8080', 10);
const host = process.env.MCP_HTTP_HOST ?? '127.0.0.1';
const allowedHosts = process.env.MCP_HTTP_ALLOWED_HOSTS
? process.env.MCP_HTTP_ALLOWED_HOSTS.split(',')
.map((h) => h.trim())
.filter(Boolean)
: ['127.0.0.1'];
const allowedOrigins = process.env.MCP_HTTP_ALLOWED_ORIGINS
? process.env.MCP_HTTP_ALLOWED_ORIGINS.split(',')
.map((o) => o.trim())
.filter(Boolean)
: undefined;

if (host === '0.0.0.0' && !process.env.MCP_HTTP_ALLOWED_HOSTS) {
console.error(
'Warning: MCP_HTTP_HOST=0.0.0.0 but MCP_HTTP_ALLOWED_HOSTS is not set. ' +
'DNS rebinding protection is limited. Set MCP_HTTP_ALLOWED_HOSTS to the expected Host header value.',
);
}

const httpServer = http.createServer(async (req, res) => {
const url = req.url ?? '/';
const method = req.method ?? 'GET';

if (url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}

if (url !== '/mcp') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
return;
}

if (method === 'GET' || method === 'DELETE') {
jsonRpcError(res, 405, -32000, 'Method not allowed');
return;
}

if (method !== 'POST') {
jsonRpcError(res, 405, -32000, 'Method not allowed');
return;
}

// Quick pre-check via Content-Length before reading the body
const contentLength = req.headers['content-length'];
if (contentLength && Number.parseInt(contentLength, 10) > BODY_SIZE_LIMIT) {
jsonRpcError(res, 413, -32600, 'Request body too large');
return;
}

let body: string;
let tooLarge: boolean;
try {
({ body, tooLarge } = await readBody(req));
} catch {
jsonRpcError(res, 500, -32603, 'Internal error reading request body');
return;
}

if (tooLarge) {
jsonRpcError(res, 413, -32600, 'Request body too large');
return;
}

let parsedBody: unknown;
try {
parsedBody = JSON.parse(body);
} catch {
jsonRpcError(res, 400, -32700, 'Parse error');
return;
}

// Fresh McpServer + transport per request — stateless isolation so concurrent callers never share state
const freshServer = new McpServer(
{ name: 'HUMAN Security MCP Server', version: MCP_VERSION },
{ capabilities: { tools: {} } },
);
registerTools(freshServer, services);

const transportOptions: StreamableHTTPServerTransportOptions = {
sessionIdGenerator: undefined,
enableDnsRebindingProtection: true,
allowedHosts,
allowedOrigins,
};
const transport = new StreamableHTTPServerTransport(transportOptions);

try {
await freshServer.connect(transport);
await transport.handleRequest(req, res, parsedBody);
} catch (err) {
console.error('Error handling MCP request:', err);
if (!res.headersSent) {
jsonRpcError(res, 500, -32603, 'Internal error');
}
} finally {
// Defer close until after the response is fully flushed so SSE streams are not truncated.
// The close frees SDK-allocated Maps/Sets/AjvJsonSchemaValidator (~2 KB/request).
res.once('finish', () => {
freshServer.close().catch((err) => console.error('Error closing MCP server instance:', err));
});
}
});

httpServer.listen(port, host, () => {
const addr = httpServer.address();
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
// HTTP/1.1 Host headers include the port for non-default ports (e.g. "127.0.0.1:8080").
// Ensure both bare-host and host:port are accepted so fetch/curl requests pass validation.
const entriesToAdd = allowedHosts
.filter((h) => !h.includes(':'))
.map((h) => `${h}:${actualPort}`)
.filter((h) => !allowedHosts.includes(h));
allowedHosts.push(...entriesToAdd);
console.error(`MCP HTTP server listening on ${host}:${actualPort}`);
});

const shutdown = () => {
httpServer.close(() => {
console.error('MCP HTTP server shut down.');
});
};

process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);

return httpServer;
}

export function createServer() {
const server = new McpServer(
{
Expand All @@ -24,10 +188,7 @@ export function createServer() {
const cyberfraudToken = process.env.HUMAN_CYBERFRAUD_API_TOKEN;
const codeDefenderToken = process.env.HUMAN_CODE_DEFENDER_API_TOKEN;

const services: {
cyberfraudService?: CyberfraudService;
codeDefenderService?: CodeDefenderService;
} = {};
const services: Services = {};

if (cyberfraudToken) {
const cyberfraudHttpClient = new HttpClient(cyberfraudToken);
Expand All @@ -47,21 +208,48 @@ export function createServer() {

registerTools(server, services);

let activeHttpServer: http.Server | undefined;

return {
start: () => {
const transport = new StdioServerTransport();
server.connect(transport).then(() => {
console.error('MCP server started...');
});
const mcpTransport = process.env.MCP_TRANSPORT ?? 'stdio';

const shutdown = () => {
server.close().then(() => {
console.error('MCP server shut down.');
if (mcpTransport === 'http') {
activeHttpServer = startHttpServer(services);
} else if (mcpTransport === 'stdio') {
const transport = new StdioServerTransport();
server.connect(transport).then(() => {
console.error('MCP server started...');
});
};

process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
const shutdown = () => {
server.close().then(() => {
console.error('MCP server shut down.');
});
};

process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
} else {
console.error(`Unknown MCP_TRANSPORT value: "${mcpTransport}". Expected "stdio" or "http".`);
process.exit(1);
}
},

stop: (): Promise<void> =>
new Promise((resolve) => {
if (activeHttpServer) {
activeHttpServer.close(() => resolve());
activeHttpServer.closeAllConnections();
activeHttpServer = undefined;
} else {
resolve();
}
}),

getPort: (): number | undefined => {
const addr = activeHttpServer?.address();
return typeof addr === 'object' && addr ? addr.port : undefined;
},
};
}
Loading