MCP Security Framework
by aself101
README.md
# MCP Security Framework
[](https://www.npmjs.com/package/mcp-secure-server)
[](LICENSE)
[](https://nodejs.org)
[](https://www.typescriptlang.org/)
A secure-by-default MCP server built on the official SDK with 5-layer validation. Provides defense-in-depth against traditional attacks and AI-driven threats.
This framework implements defense-in-depth security with zero configuration required, protecting MCP servers from path traversal, command injection, SQL injection, XSS, prototype pollution, SSRF, and 20+ additional attack vectors.
## Quick Start
### Installation
```bash
npm install mcp-secure-server
```
**HTTP transport status (0.1.0-security):** experimental until 0.2.0. It serves many clients, one
MCP session each (through 0.0.27 it answered only the first request per process), but it has no
authentication and Layer 3's budget is shared by every client of a server — see
[HTTP Transport](#http-transport). Upgrading HTTP users: clients must send the `Mcp-Session-Id`
the server issues (SDK clients do), browser clients must be listed in `allowedOrigins`, and
registrations freeze when the handler is attached.
**Upgrading to `0.0.26-security` behind a reverse proxy, load balancer or CDN:** per-client-IP
limits over HTTP no longer trust `X-Forwarded-For` or `X-Real-IP` unless you configure
`trustProxy` (the old behaviour let any client choose its own rate-limit key). Until you set it,
**every client behind the proxy shares one budget** — e.g. 60 GET/DELETE per minute for all
users together. If the proxy's own address is private (RFC 1918, ULA, loopback, link-local), Node
prints a one-time `MCP_SECURE_SERVER_TRUST_PROXY` warning; a proxy with a public address gets no
warning. Set `trustProxy` to your proxies' exact addresses before upgrading: see
[Client IP and `trustProxy`](#client-ip-and-trustproxy). Servers exposed directly need no change.
The opt-in Layer 5 rate limit now keys on the client address over HTTP instead of the
`Mcp-Session-Id` header.
**Upgrading to `0.0.23-security`:** three security fixes that can start rejecting calls a server
accepted before. A tool's `maxArgsSize` is now enforced whether or not the tool declares
`argsShape` (it was silently skipped without one); tool-call `arguments` that are not a plain
object are refused; and every byte-denominated size limit (`maxMessageSize`, `maxParamBytes`,
`suspiciousMessageSize`, `maxArgsSize`, `maxEgressBytes`) now counts UTF-8 bytes rather than
characters, so non-ASCII payloads near a limit measure up to 3x larger. Review your caps before
upgrading. See [CHANGELOG.md](CHANGELOG.md).
### Basic Usage
```typescript
import { SecureMcpServer } from 'mcp-secure-server';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
// Create secure server with a security preset
const server = new SecureMcpServer(
{ name: 'my-server', version: '1.0.0' },
{
securityLevel: 'standard', // 'basic' | 'standard' | 'paranoid' | 'custom'
// Layer 4 denies any tools/call whose name is not registered here (fail
// closed). server.registerTool() below does NOT register with Layer 4 by itself.
toolRegistry: [{ name: 'calculator', sideEffects: 'none' }]
}
);
// Register tools exactly like McpServer — registerTool(), the MCP SDK's current API
server.registerTool('calculator', {
description: 'Basic calculator',
inputSchema: { left: z.number(), right: z.number() }
}, async ({ left, right }) => {
return { content: [{ type: 'text', text: `Result: ${left + right}` }] };
});
// Connect - transport is automatically wrapped with security
const transport = new StdioServerTransport();
await server.connect(transport);
```
### Security Presets
Choose your security level with a single option:
```typescript
// Development: relaxed limits, minimal validation
const devServer = new SecureMcpServer(
{ name: 'dev', version: '1.0.0' },
{ securityLevel: 'basic' }
);
// Production: balanced security (default)
const prodServer = new SecureMcpServer(
{ name: 'prod', version: '1.0.0' },
{ securityLevel: 'standard' }
);
// High-security: maximum protection
const secureServer = new SecureMcpServer(
{ name: 'secure', version: '1.0.0' },
{ securityLevel: 'paranoid' }
);
// Custom: override specific values within a preset
const customServer = new SecureMcpServer(
{ name: 'custom', version: '1.0.0' },
{
securityLevel: 'standard',
maxRequestsPerMinute: 60 // Override just this value
}
);
```
| Preset | Use Case | Message Size | Rate Limit | Burst | Automation Detection |
|--------|----------|--------------|------------|-------|---------------------|
| `basic` | Development, testing | 100KB | 120/min | 30/10s | Disabled |
| `standard` | Production (default) | 50KB | 30/min | 10/10s | Enabled |
| `paranoid` | High-risk, compliance | 25KB | 15/min | 5/5s | Enabled (strict) |
| `custom` | Full control | You decide | You decide | You decide | You decide |
### Programmatic Preset Access
Access preset configurations programmatically for dynamic configuration, validation, or custom tooling:
```typescript
import {
SECURITY_PRESETS,
resolvePreset,
getDefaultPreset,
isValidPreset
} from 'mcp-secure-server';
// Get the default preset name
const defaultName = getDefaultPreset(); // 'standard'
// Validate user input
const userInput = 'paranoid';
if (isValidPreset(userInput)) {
const config = resolvePreset(userInput);
console.log(config.maxMessageSize); // 25600
console.log(config.maxRequestsPerMinute); // 15
}
// Iterate all presets for documentation or UI
for (const [name, config] of Object.entries(SECURITY_PRESETS)) {
console.log(`${name}: ${config.maxRequestsPerMinute} req/min`);
}
// Build dynamic configuration
function createServer(env: string) {
const level = env === 'production' ? 'paranoid' : 'basic';
return new SecureMcpServer(
{ name: 'dynamic', version: '1.0.0' },
{ securityLevel: level }
);
}
```
### With Logging (Opt-in)
```typescript
const server = new SecureMcpServer(
{ name: 'my-server', version: '1.0.0' },
{
securityLevel: 'standard',
enableLogging: true,
verboseLogging: true,
logPerformanceMetrics: true,
logLevel: 'debug'
}
);
```
Full TypeScript support with exported types for all parameters, configurations, and responses.
## Table of Contents
- [Overview](#overview)
- [Architecture](#architecture)
- [Security Layers](#security-layers)
- [Installation](#installation)
- [TypeScript Support](#typescript-support)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [Tool Policies Configuration](#tool-policies-configuration)
- [API Reference](#api-reference)
- [HTTP Transport](#http-transport)
- [Client IP and `trustProxy`](#client-ip-and-trustproxy)
- [Layer 5 Customization](#layer-5-customization)
- [Security Features](#security-features)
- [Attack Coverage](#attack-coverage)
- [Error Handling](#error-handling)
- [Claude Desktop Integration](#claude-desktop-integration)
- [Development](#development)
- [Troubleshooting](#troubleshooting)
- [Cookbook Examples](#cookbook-examples)
## Cookbook Examples
Example MCP servers demonstrating the security framework. Each server includes input validation and attack prevention.
| Server | Description | Tools | Auth |
|--------|-------------|-------|------|
| [advanced-validation-server](cookbook/advanced-validation-server) | Layer 5 custom validators (PII detection, geofencing, business hours, egress tracking) | Financial query, batch process, export data, API call | None |
| [api-wrapper-server](cookbook/api-wrapper-server) | Safe REST API wrapping with domain restrictions and rate limiting | Weather, currency conversion, news headlines | None |
| [cli-wrapper-server](cookbook/cli-wrapper-server) | Safe CLI tool wrapping with command injection prevention | Git status, image resize, PDF metadata, video encode | None |
| [database-server](cookbook/database-server) | Secure database operations with SQL injection prevention | User queries, order creation, report generation | None |
| [filesystem-server](cookbook/filesystem-server) | Protected file system access with path traversal prevention | Read files, list directories, search files | None |
| [http-server](cookbook/http-server) | Simple HTTP transport with `createHttpServer()` | Calculator, echo | None |
| [http-session-server](cookbook/http-session-server) | Several HTTP clients at once, one MCP session each: per-session logging and sampling, browser CORS, the registration freeze | Notes, slow task, summarize (sampling), session info, log-to-me | None |
| [image-gen-server](cookbook/image-gen-server) | Unified image generation across 5 providers (BFL, Google, Ideogram, OpenAI, Stability) | Generate, edit, upscale, describe images | API keys |
| [kenpom-server](cookbook/kenpom-server) | College basketball analytics and efficiency ratings | Ratings, schedules, scouting reports, player stats | KenPom login |
| [monitoring-server](cookbook/monitoring-server) | Observability with metrics, audit logging, and alerts | Security metrics, audit log, alerts, Prometheus export | None |
| [multi-endpoint-server](cookbook/multi-endpoint-server) | Multiple HTTP endpoints with `createSecureHttpHandler()` | Admin (list-users, system-stats), Public (health, status) | None |
| [nba-server](cookbook/nba-server) | NBA stats, live scores, and player data | Player stats, box scores, live scoreboard | None |
| [transaction-server](cookbook/transaction-server) | Method chaining enforcement for secure transaction workflows | Session, accounts, prepare/confirm/execute transactions | None |
See the [cookbook README](cookbook/README.md) for setup instructions and detailed documentation.
## Overview
The MCP Security Framework acts as a universal wrapper for any MCP server, providing comprehensive security validation through a multi-layered architecture. It implements:
- **5-Layer Defense Pipeline** - Structure, Content, Behavior, Semantics, and Contextual validation
- **Zero Configuration** - Security enabled by default with sensible defaults
- **Universal Compatibility** - Works with any MCP server using @modelcontextprotocol/sdk
- **Extensible Layer 5** - Add custom validators, domain restrictions, OAuth validation
- **Tested** - 1264 tests with 93.76% line coverage, including a `dist/` smoke suite that runs the published artifact in a separate process
- **Opt-in File Logging** - the audit logger is off by default; blocked requests always emit one `[SECURITY]` line to **stderr** (never stdout)
- **Performance Optimized** - Content caching and efficient pattern detection
- **Full TypeScript Support** - Complete type definitions with strict mode
## Architecture
```text
Request → Layer 1 → Layer 2 → Layer 3 → Layer 4 → Layer 5 → MCP Server
│ │ │ │ │
Structure Content Behavior Semantics Contextual
Validation Validation Validation Validation Validation
```
### Visual Overview
```text
MCP Security Framework (5 Layers by Default)
│
┌─────────────┬─────────────┬─────────────┬─────────────┬─────────────┐
│ │ │ │ │ │
┌───▼────┐ ┌─────▼─────┐ ┌────▼────┐ ┌────▼─────┐ ┌─────▼──────┐
│ Layer 1│ │ Layer 2 │ │ Layer 3 │ │ Layer 4 │ │ Layer 5 │
│ Struct.│ │ Content │ │ Behavior│ │ Semantics│ │ Contextual │
└────────┘ └───────────┘ └─────────┘ └──────────┘ └────────────┘
│JSON-RPC│ │Injection │ │Rate │ │Tool │ │Custom │
│Format │ │Detection │ │Limiting │ │Contracts │ │Validators │
│Size │ │XSS/SQLi │ │Burst │ │Quotas │ │Domain/OAuth│
│Encoding│ │Path Trav. │ │Patterns │ │Policies │ │Response Val│
└────────┘ └───────────┘ └─────────┘ └──────────┘ └────────────┘
```
## Security Layers
### Layer 1 - Structure Validation
Validates the fundamental structure of incoming JSON-RPC messages.
**Protections:**
- JSON-RPC 2.0 format validation
- Request size limits (default: 50KB)
- Message encoding validation. Refused anywhere: null bytes; every bidirectional embedding, override and isolate (U+202A–U+202E, U+2066–U+2069, the "Trojan Source" set); zero-width space U+200B, word joiner U+2060, and U+FEFF. The zero-width joiner U+200D and non-joiner U+200C are allowed only where they carry meaning: inside an emoji sequence, or after a letter of a script that shapes with them (Arabic, Syriac, Mongolian, the Indic scripts). Between Latin letters they are refused. Left-to-right and right-to-left marks pass.
- Parameter count limits
- Per-string parameter length limits (default: 5,000 chars)
- Method name length limits
**Configuration:**
```typescript
{
maxMessageSize: 50000, // Maximum message size in bytes
maxParamCount: 100, // Maximum recursive parameter count (set to Infinity to disable)
maxStringLength: 5000 // Maximum length of any single string parameter value (chars)
}
```
Method name length is capped at 100 characters. This limit is not configurable — MCP
method names are protocol-level identifiers (`tools/call`, `resources/read`, …) and
never legitimately approach it.
> **Note:** `maxMessageSize` must leave headroom above `maxStringLength` — the message
> envelope is larger than the string it carries, and the message-size check fires first.
### Layer 2 - Content Validation
Detects and blocks malicious content patterns in request parameters.
**Protections:** Path traversal, command injection, SQL/NoSQL injection, XSS, prototype pollution, XML entity attacks (XXE), CRLF injection, SSRF, CSV injection, LOLBins, GraphQL introspection, deserialization attacks, JNDI/Log4Shell, buffer overflow patterns, and more.
See [SECURITY.md](https://github.com/aself101/mcp-secure-server/blob/main/SECURITY.md#attack-vectors) for the complete list of 200+ attack patterns with examples.
**Configuration:**
```typescript
{
maxParamBytes: 50000, // Max serialized params payload (bytes)
contentValidation: {
enabled: true,
debugMode: false // Enable for detailed pattern match info
}
}
```
> **Note — the size caps stack.** A large payload faces four independent ceilings, and the
> first one to fire names itself in the rejection: `maxMessageSize` (Layer 1 envelope),
> `maxStringLength` (Layer 1, per string), `maxParamBytes` (Layer 2, serialized params),
> and `suspiciousMessageSize` (Layer 3, hard block). Tools that legitimately accept large
> structured payloads (bulk saves, document stores) need all four raised together — raising
> only one moves the rejection to the next ceiling in the stack. The per-tool `maxArgsSize`
> (Layer 4) is a fifth, tool-scoped ceiling that cannot admit what the global caps reject.
> **Note — shell-access patterns require invocation context.** The `command.shellAccess`
> patterns run at every security level (ALWAYS_CHECK), including STORAGE tools with
> `relaxedFields`. As of 0.0.19-security they match shell *invocations*
> (`powershell -enc …`, `cmd /c …`, non-shebang `/bin/sh`), not bare mentions — stored
> prose like "PowerShell users" or a `#!/bin/sh` shebang no longer trips them. Known
> limitation: stored content quoting a *complete* shell invocation (e.g. a security
> report's reverse-shell example) is still rejected at STORAGE level. This is deliberate;
> if it becomes a problem for your workload, the escape hatch is field-level exclusion via
> `relaxedFields`, whose content is not pattern-scanned at all.
### Layer 3 - Behavior Validation
Rate limiting and request pattern analysis to prevent abuse.
**Protections:**
- Requests per minute rate limiting
- Requests per hour rate limiting
- Burst detection (configurable time window)
- Automation detection via timing analysis
- Large message flagging
**Configuration:**
```typescript
{
maxRequestsPerMinute: 30, // Rate limit per minute
maxRequestsPerHour: 500, // Rate limit per hour
burstThreshold: 10, // Max requests in burst window
burstWindowMs: 10000, // Burst detection window in ms (default: 10s)
suspiciousMessageSize: 20000, // BLOCK messages larger than this (bytes) — a hard
// rejection, not a log-only flag; keep aligned with
// maxMessageSize/maxParamBytes for large-payload tools
automationDetection: {
enabled: true, // Enable timing-based automation detection
sampleSize: 5, // Number of requests to analyze
maxVariance: 50, // Max timing variance (ms) before flagging
minInterval: 100, // Min avg interval (ms) to flag as automation
maxInterval: 2000 // Max avg interval (ms) to flag as automation
}
}
```
**Automation Detection:** Analyzes request timing patterns to detect automated scripts. When enabled, it monitors the variance in request intervals - suspiciously consistent timing (low variance) indicates automation rather than human interaction.
### Layer 4 - Semantic Validation
Tool contract enforcement and resource access policies.
**Protections:**
- Tool argument validation against schemas
- Response size limits (egress control)
- Per-tool quota enforcement
- Side effect declarations
- Filesystem access control via resource policies
- Session management
- Method chaining enforcement (opt-in)
**Configuration:**
```typescript
{
toolRegistry: [
{
name: 'my-database-tool',
sideEffects: 'write', // 'none' | 'read' | 'write' | 'network'
maxArgsSize: 5000, // Max argument size in bytes
maxEgressBytes: 100000, // Max response size
quotaPerMinute: 30,
quotaPerHour: 500,
argsShape: { // Expected argument schema
query: { type: 'string' },
limit: { type: 'number' }
}
}
],
resourcePolicy: {
allowedSchemes: ['file'],
rootDirs: ['./data', './public'],
denyGlobs: ['/etc/**', '**/*.key', '**/.env'],
maxPathLength: 4096,
maxReadBytes: 2000000 // 2MB max file read
},
maxSessions: 5000,
sessionTtlMs: 1800000 // 30 minutes
}
```
#### Method Chaining Enforcement
Layer 4 can enforce valid method call sequences to prevent abuse patterns like calling dangerous tools without proper initialization.
**Enable chaining enforcement:**
```typescript
{
enforceChaining: true, // Enable method chaining (default: false)
chainingDefaultAction: 'deny', // 'allow' | 'deny' when no rule matches
chainingRules: [
// Allow any method to call initialize
{ from: '*', to: 'initialize' },
// After initialize, can list tools, resources or resource templates
{ from: 'initialize', to: 'tools/list' },
{ from: 'initialize', to: 'resources/list' },
{ from: 'initialize', to: 'resources/templates/list' },
// After listing tools, can call them
{ from: 'tools/list', to: 'tools/call' },
// Tool-to-tool calls allowed
{ from: 'tools/call', to: 'tools/call' },
]
}
```
**ChainingRule type:**
```typescript
interface ChainingRule {
from: string; // Method to transition from ('*' for any)
to: string; // Method to transition to ('*' for any)
fromTool?: string; // Tool name glob pattern (e.g., 'file-*', '*-http*')
toTool?: string; // Tool name glob pattern
fromSideEffect?: SideEffectType; // 'none' | 'read' | 'write' | 'network'
toSideEffect?: SideEffectType;
action?: 'allow' | 'deny'; // Default: 'allow'
id?: string; // Rule identifier for logging (named in denial messages)
}
```
`ChainingRule` and `SideEffectType` are exported from `mcp-secure-server`.
**Default method allowlist.** Independently of chaining, Layer 4 refuses any method not in its
allowlist with `INVALID_MCP_METHOD`. The defaults are `initialize`, `ping`, `tools/list`,
`tools/call`, `resources/list`, `resources/read`, `resources/templates/list` (since
0.0.23-security — before that every `ResourceTemplate` a server registered was undiscoverable),
`prompts/list`, `prompts/get`, and the `notifications/initialized`, `notifications/cancelled` and
`notifications/progress` notifications. The default chaining rules include
`initialize → resources/templates/list` and `resources/templates/list → resources/read`. Add or
remove methods with the `methodSpec` option (type `MethodSpecOverride`), which merges per method
over these defaults.
**Advanced example - block dangerous transitions:**
```typescript
{
enforceChaining: true,
chainingDefaultAction: 'allow', // Allow by default
chainingRules: [
// Block read tools from calling write tools directly
{
from: 'tools/call',
to: 'tools/call',
fromSideEffect: 'read',
toSideEffect: 'write',
action: 'deny',
id: 'no-read-to-write'
},
// Block file-* tools from calling *-http* tools
{
from: 'tools/call',
to: 'tools/call',
fromTool: 'file-*',
toTool: '*-http*',
action: 'deny',
id: 'no-file-to-http'
}
]
}
```
Rules are evaluated first-match-wins. Tool patterns use simple glob matching (`*` = any chars, `?` = single char).
### Layer 5 - Contextual Validation
Custom validators, domain restrictions, and response filtering. Fully extensible.
**Protections:**
- Custom validator registration with priorities
- Domain blocklist/allowlist enforcement
- OAuth URL validation
- Response content validation (PII detection, etc.)
- Cross-request state via context store
- Global rules that run before validators
**Configuration:**
```typescript
{
contextual: {
enabled: true, // Set false to disable Layer 5
domainRestrictions: {
enabled: true,
blockedDomains: ['evil.com', 'malicious.net'],
allowedDomains: [] // Empty = allow all except blocked
},
oauthValidation: {
enabled: true,
allowedDomains: ['oauth.example.com'],
blockDangerousSchemes: true
},
rateLimiting: {
enabled: true,
limit: 20,
windowMs: 60000
},
maxContextEntries: 10000 // Context-store cap; expired swept, then oldest evicted
}
}
```
## Installation
### From npm
```bash
# Install in your project (it is a library; there is no CLI to install globally)
npm install mcp-secure-server
```
The examples are TypeScript. Run one with `npx tsx server.ts`, or compile with `tsc`; plain
`node server.ts` works only on Node versions that strip types natively (22.6+), not on the
Node 18 and 20 this package supports.
### From Source
```bash
# Clone the repository
git clone https://github.com/aself101/mcp-secure-server.git
cd mcp-secure-server
# Install dependencies
npm install
# Build TypeScript
npm run build
```
**Dependencies** (regular, installed with the package — not peer dependencies):
- `@modelcontextprotocol/sdk` - MCP SDK (the server you wrap is built on the copy installed here)
- `zod` - Schema validation
- `minimatch` - Glob matching for tool-policy names
- `winston` - The optional audit logger's transports
## TypeScript Support
This package is written in TypeScript with strict mode enabled (`noUncheckedIndexedAccess`, `strictNullChecks`). All exports include complete type definitions.
`SecureMcpServer`'s registration methods — `tool`, `registerTool`, `resource`, `registerResource`,
`prompt`, `registerPrompt` — carry the underlying `McpServer`'s own types (since 0.0.24-security;
they were `(...args: unknown[]) => unknown` before), so handler arguments are inferred from your
Zod schema exactly as with `McpServer`, and a handler must return a valid tool result.
**Prefer `registerTool()` / `registerResource()` / `registerPrompt()`.** The MCP SDK marks
`tool()`, `resource()` and `prompt()` `@deprecated` in favour of the `register*` forms, and because
these methods now carry the SDK's own types, that deprecation reaches your editor and linters
(`@typescript-eslint/no-deprecated`). Both forms work and both are secured identically —
`registerTool()` handlers get the same Layer 5 response wrapping as `tool()` handlers. Some examples
below and in the cookbook still use `tool()`.
**Use the same `zod` the MCP SDK resolves.** The SDK's tool typings accept Zod 3 and Zod 4 schemas
through a compatibility layer; when your project's `zod` is a *different copy* from the one the
SDK's types see (for example Zod 3.x in your app beside Zod 4.x under the SDK), TypeScript compares
the two type trees structurally and `tsc` can take minutes and exhaust memory. This is SDK
behaviour — plain `McpServer` does the same (measured: 55 s / 4.4 GB, out of memory, on a cookbook
server; 0.3 s / 330 MB with one copy). Check with `npm ls zod`; align to a single Zod 4 version.
### Exported Types
```typescript
import {
// Main classes
SecureMcpServer,
SecureTransport,
ContextualValidationLayer,
ContextualConfigBuilder,
// Factory functions
createContextualLayer,
// Type guards
isSeverity,
isViolationType,
isError,
getErrorMessage,
// Types
SecurityOptions,
ValidationResult,
Severity,
ViolationType,
ToolSpec,
ResourcePolicy,
ValidationContext
} from 'mcp-secure-server';
```
### Type-Safe Configuration
```typescript
import { SecureMcpServer } from 'mcp-secure-server';
import type { SecureMcpServerOptions } from 'mcp-secure-server';
const options: SecureMcpServerOptions = {
securityLevel: 'standard',
maxMessageSize: 50000,
maxParamCount: 100, // Recursive key count limit (Infinity to disable)
maxStringLength: 5000, // Per-string parameter length limit (chars)
maxParamBytes: 50000, // Serialized params payload limit (bytes, Layer 2)
maxRequestsPerMinute: 30,
enableLogging: true,
contextual: {
enabled: true,
domainRestrictions: {
enabled: true,
blockedDomains: ['evil.com']
}
}
};
const server = new SecureMcpServer(
{ name: 'my-server', version: '1.0.0' },
options // TypeScript validates all options
);
```
> `SecureMcpServerOptions` is the constructor's actual parameter type and always carries
> the full option set. The older `SecurityOptions` name is now a deprecated alias of it —
> it was previously a separate interface that drifted out of sync with the real options.
### Validation Results
```typescript
interface ValidationResult {
passed: boolean;
allowed: boolean; // alias of passed (backward compatibility)
severity: Severity; // 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
reason: string | null; // sanitized failure reason
violationType: ViolationType | string | null; // 'PATH_TRAVERSAL' | 'SQL_INJECTION' | ...
confidence: number; // 0.0 - 1.0
timestamp: number;
layerName: string | null;
detectionLayer?: string;
validatorSource?: string;
validationTime?: number;
}
```
All non-suffixed fields are **required** on the interface. In plain JavaScript, custom
Layer 5 validators may return partial literals like `{ passed: true }` — the framework
normalizes them at runtime. In TypeScript, annotate custom validators loosely (or return
the full shape); a literal `{ passed: true }` does not satisfy `ValidationResult`.
### Building from Source
```bash
# Install dependencies
npm install
# Build TypeScript to JavaScript
npm run build
# Output is in dist/
ls dist/
# index.js, index.d.ts, security/*.js, security/*.d.ts, types/*.d.ts
```
## Configuration
### Full Configuration Reference
```typescript
const server = new SecureMcpServer(
{ name: 'my-server', version: '1.0.0' },
{
// ═══════════════════════════════════════════
// Layer 1 & 2 - Structure & Content Validation
// ═══════════════════════════════════════════
maxMessageSize: 50000, // Max message size (bytes)
maxParamCount: 100, // Max recursive parameters (Infinity to disable)
maxStringLength: 5000, // Max length of any single string parameter (chars)
maxParamBytes: 50000, // Max serialized params payload (bytes, Layer 2)
// (method name length is fixed at 100 chars — not configurable)
// ═══════════════════════════════════════════
// Layer 2 - Content Validation
// ═══════════════════════════════════════════
// Enabled by default with all pattern detection
// ═══════════════════════════════════════════
// Layer 3 - Behavior Validation
// ═══════════════════════════════════════════
maxRequestsPerMinute: 30, // Rate limit per minute
maxRequestsPerHour: 500, // Rate limit per hour
burstThreshold: 10, // Max requests in burst window
burstWindowMs: 10000, // Burst window duration (ms)
suspiciousMessageSize: 20000, // Block large messages (bytes) — hard rejection
automationDetection: { // Timing-based automation detection
enabled: true, // Enable/disable detection
sampleSize: 5, // Requests to analyze
maxVariance: 50, // Max timing variance (ms)
minInterval: 100, // Min interval to flag (ms)
maxInterval: 2000 // Max interval to flag (ms)
},
// ═══════════════════════════════════════════
// Layer 4 - Semantic Validation
// ═══════════════════════════════════════════
toolRegistry: [ // Tool constraints
{
name: 'my-tool',
sideEffects: 'write',
maxArgsSize: 5000,
maxEgressBytes: 100000,
quotaPerMinute: 30
}
],
resourcePolicy: { // Filesystem access control
allowedSchemes: ['file'],
rootDirs: ['./data'],
denyGlobs: ['/etc/**', '**/*.key'],
maxReadBytes: 2000000
},
maxSessions: 5000,
sessionTtlMs: 1800000,
methodSpec: { // Method allowlist — merges per method over the defaults
shape: {
'completion/complete': { required: [] }, // add a method the defaults refuse
'prompts/get': null, // null removes a default method
}
},
enforceChaining: false, // Enable method chaining (default: false)
chainingDefaultAction: 'deny', // 'allow' | 'deny' when no rule matches
chainingRules: [ // Method transition rules
{ from: '*', to: 'initialize' },
{ from: 'initialize', to: 'tools/list' },
{ from: 'tools/list', to: 'tools/call' },
{ from: 'tools/call', to: 'tools/call' },
// Advanced: tool patterns and side effects
// { from: 'tools/call', to: 'tools/call', fromTool: 'read-*', toTool: 'write-*', action: 'deny' }
],
// ═══════════════════════════════════════════
// Layer 5 - Contextual Validation
// ═══════════════════════════════════════════
contextual: {
enabled: true, // false to disable Layer 5
domainRestrictions: {
enabled: true,
blockedDomains: ['evil.com'],
allowedDomains: [] // Empty = allow all except blocked
},
oauthValidation: {
enabled: true,
allowedDomains: ['oauth.example.com'],
blockDangerousSchemes: true
},
rateLimiting: {
enabled: true,
limit: 20,
windowMs: 60000
}
},
// ═══════════════════════════════════════════
// Error responses
// ═══════════════════════════════════════════
enableDetailedErrors: false, // Include redacted violation reason in top-level
// error.message (detail is always in data.reason)
// ═══════════════════════════════════════════
// Logging (file/audit logger disabled by default; blocked requests still
// emit one [SECURITY] line to stderr — see ErrorSanitizerOptions.enableSecurityLogging)
// ═══════════════════════════════════════════
enableLogging: false, // Enable security logging
verboseLogging: false, // Detailed decision logs
logPerformanceMetrics: false, // Timing statistics
logLevel: 'info', // 'debug' | 'info' | 'warn' | 'error'
logDir: './logs', // Log directory; also settable via LOG_DIR env
// ═══════════════════════════════════════════
// Warnings (see HTTP Transport → Warnings)
// ═══════════════════════════════════════════
onWarning: (message, code) => console.warn(`[${code}] ${message}`), // default: process.emitWarning
silenceWarnings: [] // codes to drop, each named; there is no "all"
}
);
```
### Log Directory Resolution
The log directory is resolved in this order:
1. `logDir` option passed to the constructor
2. `LOG_DIR` environment variable
3. `<cwd>/logs` (default)
A relative value is resolved against the current working directory. Set `logDir`
explicitly when the process may run with a non-writable working directory — for
example, MCP hosts (such as Claude Desktop) that launch servers with `cwd="/"`,
where the default would resolve to the unwritable `/logs`.
If the resolved directory cannot be created or written, the logger **degrades to
no-op logging** rather than crashing the host (logging must never crash the
application). When this happens it emits a one-time warning to **stderr**, and
`getSecurityStats().logger.fileLoggingAvailable` reports `false` (with
`writeErrors`/`lastWriteError` for late failures) so the degraded state is
observable rather than silent.
## Tool Policies Configuration
Tool policies allow you to define security levels for individual MCP tools, enabling context-aware content validation. Tools that store documentation can have relaxed pattern detection, while tools that execute commands use full validation.
### Security Levels
| Level | Description | Use Case |
|-------|-------------|----------|
| `EXECUTION` | Full validation - all attack patterns checked | Command execution, file writes, system operations |
| `QUERY` | Standard validation - SQL/NoSQL patterns added | Database queries, API calls, search operations |
| `STORAGE` | Relaxed validation - critical patterns only | Issue trackers, notes, documentation storage |
| `DISPLAY` | Minimal validation - XSS and deserialization only | Read-only queries, help output, status displays |
### Configuration File
Create a `tool-policies.json` file in your project root or specify a path via the `TOOL_POLICIES_PATH` environment variable.
**File resolution order:**
1. `TOOL_POLICIES_PATH` environment variable
2. `./tool-policies.json` (current working directory)
3. `~/.config/mcp-secure-server/tool-policies.json` (user config)
### Basic Example
```json
{
"version": "2.0",
"tools": {
"save_note": {
"level": "STORAGE",
"relaxedFields": ["content", "title"],
"description": "Stores user notes"
},
"get_status": {
"level": "DISPLAY",
"description": "Read-only status query"
},
"execute_command": {
"level": "EXECUTION",
"description": "Runs shell commands"
}
},
"defaultLevel": "EXECUTION"
}
```
### Full Schema
```json
{
"version": "2.0",
"basePolicies": {
"storage-content": {
"level": "STORAGE",
"relaxedFields": ["content", "description", "title"],
"description": "Base policy for content storage tools"
},
"display-readonly": {
"level": "DISPLAY",
"description": "Base policy for read-only tools"
}
},
"patterns": [
{ "match": "get_*", "policy": "display-readonly" },
{ "match": "list_*", "policy": "display-readonly" },
{ "match": "search_*", "policy": "display-readonly" },
{ "match": "{save,create,update}_*", "policy": "storage-content" }
],
"tools": {
"execute_sql": {
"level": "QUERY",
"description": "Database queries with SQL injection checks"
},
"save_document": "storage-content",
"dangerous_operation": {
"level": "EXECUTION",
"description": "High-risk operation requiring full validation"
}
},
"defaultLevel": "EXECUTION"
}
```
### Schema Reference
| Field | Type | Description |
|-------|------|-------------|
| `version` | `"2.0"` | Required. Schema version (must be `"2.0"`) |
| `basePolicies` | `object` | Optional. Reusable policy definitions |
| `patterns` | `array` | Optional. Glob patterns for tool matching |
| `tools` | `object` | Optional. Explicit tool policy definitions |
| `defaultLevel` | `string` | Optional. Fallback level for unknown tools |
### Policy Object
```typescript
{
level: 'EXECUTION' | 'QUERY' | 'STORAGE' | 'DISPLAY', // Security level
relaxedFields?: string[], // Fields with relaxed validation
skipPatterns?: string[], // Pattern categories to skip
description?: string, // Documentation
extends?: string // Inherit from base policy
}
```
### Pattern Matching
Patterns use [minimatch](https://github.com/isaacs/minimatch) glob syntax:
| Pattern | Matches |
|---------|---------|
| `get_*` | `get_users`, `get_status`, `get_config` |
| `*_issues` | `query_issues`, `search_issues`, `list_issues` |
| `{get,list}_*` | `get_users`, `list_users`, `get_config`, `list_items` |
| `file-*` | `file-read`, `file-write`, `file-delete` |
| `v?_tool` | `v1_tool`, `v2_tool` |
**Resolution order:**
1. Explicit tool definitions (highest priority)
2. Pattern matching (first match wins)
3. `defaultLevel` from config
4. `EXECUTION` level (secure default)
### Inheritance with `extends`
Policies can inherit from base policies and add/override fields:
```json
{
"version": "2.0",
"basePolicies": {
"base-storage": {
"level": "STORAGE",
"relaxedFields": ["content"]
}
},
"tools": {
"save_note": {
"extends": "base-storage",
"relaxedFields": ["extra_field"],
"description": "Inherits STORAGE level, merges relaxedFields"
}
}
}
```
The result for `save_note`:
- `level`: `STORAGE` (inherited)
- `relaxedFields`: `["content", "extra_field"]` (merged and deduplicated)
### Relaxed Fields
The `relaxedFields` array specifies parameter names that should use `STORAGE`-level validation regardless of the tool's overall level. Useful for tools that have both sensitive and content parameters:
```json
{
"tools": {
"create_issue": {
"level": "QUERY",
"relaxedFields": ["description", "title"],
"description": "QUERY level for project/priority, STORAGE for text content"
}
}
}
```
### Runtime Registration
You can also register tool policies programmatically:
```typescript
import { registerToolPolicy } from 'mcp-secure-server';
registerToolPolicy('my_custom_tool', {
level: 'STORAGE',
relaxedFields: ['content', 'notes'],
description: 'Custom documentation tool'
});
```
### Loading Configuration Programmatically
```typescript
import { initializeToolPolicies, resetToolPolicies } from 'mcp-secure-server';
// Load from object
initializeToolPolicies({
version: '2.0',
patterns: [
{ match: 'get_*', policy: { level: 'DISPLAY' } }
],
defaultLevel: 'QUERY'
});
// Reset to defaults
resetToolPolicies();
```
### Validation Behavior by Level
| Level | Checks SQL/NoSQL | Checks Command Injection | Checks Path Traversal | Checks XSS |
|-------|-----------------|-------------------------|----------------------|------------|
| `EXECUTION` | ✅ All | ✅ All (6 sub-categories) | ✅ | ✅ All |
| `QUERY` | ✅ All | ⚠️ Critical only (shellAccess, executionWrappers) | ✅ | ✅ All |
| `STORAGE` | ❌ | ⚠️ Critical only (shellAccess, executionWrappers) | ❌ | ✅ Critical |
| `DISPLAY` | ❌ | ⚠️ Critical only (shellAccess, executionWrappers) | ❌ | ✅ Critical |
All levels check critical sub-categories (shell access, execution wrappers, XSS basic/advanced vectors, deserialization). EXECUTION_ONLY sub-categories (systemInfo, fileOperations, networkOperations, basicInjection) run only at EXECUTION level to avoid false positives on common words like "top", "curl", "grep" in documentation content.
### Advanced Tool Policy Helpers
Additional utilities for programmatic tool policy management:
```typescript
import {
getToolsByLevel,
isRelaxedField,
isValidSecurityLevel,
defaultToolPolicies
} from 'mcp-secure-server';
// Get all tools configured at a specific security level
const storageLevelTools = getToolsByLevel('STORAGE');
// Returns: ['save_note', 'create_document', ...]
// Check if a field has relaxed validation for a tool
if (isRelaxedField('save_note', 'content')) {
// 'content' field uses STORAGE-level validation
}
// Validate security level strings
if (isValidSecurityLevel(userInput)) {
// userInput is 'EXECUTION' | 'QUERY' | 'STORAGE' | 'DISPLAY'
}
// Access default policies (useful for extending)
const defaults = defaultToolPolicies;
```
**Use cases:**
- **Auditing:** List all tools at each security level
- **Debugging:** Check if a specific field is relaxed
- **Dynamic configuration:** Validate user-provided security levels
- **Testing:** Access defaults for baseline comparison
## API Reference
### SecureMcpServer
Drop-in replacement for McpServer with built-in 5-layer security.
```typescript
import { SecureMcpServer } from 'mcp-secure-server';
const server = new SecureMcpServer(serverInfo, options);
```
#### MCP SDK Passthrough Methods
SecureMcpServer delegates to the underlying McpServer for most operations. Some methods add security enhancements.
**Enhanced Methods** (security added):
| Method | Enhancement |
|--------|-------------|
| `tool()` | Response validation via Layer 5, under the tool's current name (also after a rename); the handler receives every SDK argument |
| `registerTool()` | Response validation via Layer 5, as `tool()` |
| `connect()` | Wraps transport with SecureTransport |
| `sendLoggingMessage(params, sessionId?)` | With an HTTP session's id, reaches that session only; without an id, the stdio connection; an id that names no session is dropped |
| `getClientCapabilities(sessionId?)` / `getClientVersion(sessionId?)` | With an HTTP session's id, that session's client; otherwise the stdio client |
Every registration method (`tool`, `registerTool`, `resource`, `registerResource`, `prompt`,
`registerPrompt`), `registerCapabilities()`, and every call through a returned handle is recorded
so HTTP sessions can replay it, and throws `LateRegistrationError` (with `method` and
`registrationName`) once an HTTP handler is attached to the server — stdio use of the same server
object included. Register everything first; see *Registrations freeze when a handler is
attached*.
**"stdio" in this README means the connection made through `connect()`** — usually a
`StdioServerTransport`, since HTTP goes through `createSecureHttpHandler` and the server
factories instead. A transport you pass to `connect()` yourself, an SDK HTTP transport included,
is that connection: it gets `SecureTransport`'s validation and every "stdio" statement here, and
none of the HTTP handler's sessions, `Origin` checks or bounds.
**Passthrough Methods** (delegation to McpServer):
```typescript
// Resource and prompt registration
server.resource(name, uri, handler);
server.prompt(name, description, handler);
// Connection management
await server.close();
server.isConnected();
// Notification methods (MCP SDK passthrough)
server.sendResourceListChanged();
server.sendToolListChanged();
server.sendPromptListChanged();
```
**Example with all registration types:**
```typescript
const server = new SecureMcpServer({ name: 'demo', version: '1.0.0' }, {
toolRegistry: [{ name: 'add', sideEffects: 'none' }] // Layer 4 refuses tools not listed here
});
// Tool registration (response validation enabled)
server.tool('add', 'Add numbers', { a: z.number(), b: z.number() },
async ({ a, b }) => ({ content: [{ type: 'text', text: `${a + b}` }] })
);
// Resource registration (passthrough)
server.resource('config', 'config://app', async () => ({
contents: [{ uri: 'config://app', text: JSON.stringify(config) }]
}));
// Prompt registration (passthrough)
server.prompt('greeting', 'Generate greeting', async () => ({
messages: [{ role: 'user', content: { type: 'text', text: 'Hello!' } }]
}));
await server.connect(new StdioServerTransport());
```
#### Security Methods
```typescript
// Get security statistics
const stats = server.getSecurityStats();
// {
// server: { uptime, totalLayers, enabledLayers, loggingEnabled },
// behaviorLayer?: { ...request/burst counters },
// logger?: { ...logging health, e.g. fileLoggingAvailable } // only when logging enabled
// }
// Get detailed security report (requires enableLogging: true)
const report = server.getVerboseSecurityReport();
// Generate full report to file (requires enableLogging: true)
await server.generateSecurityReport();
// Graceful shutdown with final report
await server.shutdown();
```
#### Property Accessors
```typescript
server.mcpServer; // Access underlying McpServer
server.server; // Access underlying Server
server.validationPipeline; // Access validation pipeline
```
### SecureTransport
Low-level transport wrapper for custom implementations.
```typescript
import { SecureTransport } from 'mcp-secure-server';
const secureTransport = new SecureTransport(
transport, // Original transport
validator, // Validation function
{
errorSanitizer // Optional error sanitizer
}
);
```
### HTTP Transport
> **Experimental in 0.1.0.** The transport serves many clients, one MCP session each, but it has
> **no authentication**, session ids are **bearer credentials**, and Layer 3's rate limits are
> **shared by every client of a server**. It is not for untrusted multi-tenant use until 0.2.0,
> which adds per-client budgets; the label is removed then, not before. Removing it will not add
> authentication: the exposure warning (below) carries that on its own and stays. Each process
> that creates a handler is reminded once at runtime (`MCP_SECURE_SERVER_HTTP_EXPERIMENTAL`, see
> *Warnings*).
>
> *Through 0.0.27 the transport answered only the first request per process:* it shared one
> stateless SDK transport, which the SDK (1.26 and later) refuses to reuse, so every later request
> got 500 until restart. Every HTTP test sent one request to a fresh server, so none could see it.
For remote MCP servers, use the built-in HTTP transport with security validation. Zero external dependencies - uses `node:http` directly.
```typescript
import { SecureMcpServer } from 'mcp-secure-server';
import { z } from 'zod';
const server = new SecureMcpServer(
{ name: 'my-server', version: '1.0.0' },
{
enableLogging: true,
// Layer 4 refuses any tools/call whose name is not listed here (fail closed).
toolRegistry: [{ name: 'add', sideEffects: 'none' }]
}
);
// Register everything first: creating the HTTP server freezes registrations.
server.tool('add', 'Add two numbers', {
a: z.number(),
b: z.number()
}, async ({ a, b }) => ({
content: [{ type: 'text', text: `${a + b}` }]
}));
const httpServer = server.createHttpServer({ endpoint: '/mcp' });
httpServer.listen(3000, '127.0.0.1', () => {
console.log('MCP server listening on http://127.0.0.1:3000/mcp');
});
```
**Bind `127.0.0.1` unless something in front of the server authenticates clients.** Anything
that can reach the port can open a session and call every tool. A handler whose listener is
bound beyond loopback (`0.0.0.0` included) warns once with `MCP_SECURE_SERVER_HTTP_EXPOSED`. In
a container, where the server must bind `0.0.0.0` for the port to be published, publish it to
the host's loopback (`docker run -p 127.0.0.1:3000:3000 …`) and silence the code by name.
The [`http-session-server`](cookbook/http-session-server) cookbook example runs several clients
at once: per-session logging and sampling, a browser client, the registration freeze, and a
request log. It has been driven by Claude Code, the MCP Inspector and SDK clients.
**Configuration options:**
```typescript
interface HttpServerOptions {
endpoint?: string; // MCP endpoint path (default: '/mcp'; server factories only)
maxBodySize?: number; // Max body size in bytes (default: 51200 = 50KB)
requestTimeout?: number; // Body parse timeout in ms (default: 30000)
allowedOrigins?: string[]; // Browser origins allowed (default: [] — every browser origin refused)
allowedHosts?: string[]; // Exact Host values accepted (default: no Host check; [] throws)
maxHttpSessions?: number; // Sessions per handler (default: 1000)
maxHttpSessionsPerClient?: number; // Sessions per client key (default: 10)
httpSessionIdleTtlMs?: number; // Close a session idle this long (default: 1800000 = 30 min)
sessionlessRequestsPerMinute?: number; // Per-client GET/DELETE on known sessions (default: 60)
sessionlessRequestsPerHour?: number; // Per-client GET/DELETE on known sessions (default: 600)
trustProxy?: number | string[]; // Proxies to trust for X-Forwarded-For (default: 0 = none)
}
```
The HTTP session options are not Layer 4's: `maxSessions` and `sessionTtlMs` (Layer 4) govern
the sweep of the method-chaining state Layer 4 keeps per session — not the number of MCP
sessions, and not quotas, which Layer 4 keeps per tool for the whole server. The
`sessionlessRequests*` options meter GET and DELETE per client; the name predates sessions.
#### Sessions
A client starts a session with `initialize`; the response carries a server-issued id
(`crypto.randomUUID()`) in `Mcp-Session-Id`, and every later request must send it. Each session
has its own `McpServer` and SDK transport, built from the server's registrations.
| Request | Answer |
|---|---|
| `initialize` POST, no header | a new session (subject to the bounds below) |
| any other request without `Mcp-Session-Id` | 400 before the pipeline; charged to the error lockout unless it is a well-formed JSON-RPC POST (clients probe before initializing — Claude Code sends `server/discover`) |
| a session id the server did not issue, or has closed | 404, always — never a 429; 404s are not throttled |
| POST with a known id | the 5-layer pipeline, then that session's server |
| GET with a known id | that session's SSE stream (one at a time; a second gets 409), closed after 30 minutes — the session survives |
| DELETE with a known id | ends that session |
| a JSON-RPC batch | 400 (`-32600`); batches were removed from MCP in 2025-06-18 |
**A session id is a bearer credential:** whoever holds it is that session. The package logs
only a hash of it; do the same in your validators, tool handlers and request logs, which see the
raw id (`extra.sessionId`, `extra.requestInfo.headers['mcp-session-id']`):
```typescript
import { createHash } from 'node:crypto';
const logId = (id: string) => createHash('sha256').update(id).digest('hex').slice(0, 12); // the package's form
```
The pipeline's `context.sessionId` is the server-issued id, so Layer 4 chaining state is per
session. Notifications (`notifications/initialized`, `cancelled`, `progress`, …) are neither
checked against chaining rules nor recorded, over stdio too: chaining governs request sequences,
and a cancellation must always arrive.
**Bounds** (per handler):
| Bound | Default | When reached |
|---|---|---|
| Sessions | `maxHttpSessions` 1000 | 503, `X-RateLimit-Scope: sessions` |
| Sessions per client key | `maxHttpSessionsPerClient` 10 | the least-recently-used session of the same key idle for 60 s or more is closed to make room — behind one NAT or proxy that can be another user's; otherwise 503, `X-RateLimit-Scope: client-sessions` |
| Idle time | `httpSessionIdleTtlMs` 30 min | the session is closed (swept every 30 s); a request in flight keeps it open |
| `initialize` | 60 s | a session that has not finished initializing is reclaimed; its request gets 503 `sessions`, retry |
| GET stream | 30 min | the stream is closed; the TypeScript SDK client reopens it, other clients may need to |
**A `client-sessions` 503 usually means many users share one key** — they reach the server
through one proxy or NAT. Configure `trustProxy` (below) first. Raising
`maxHttpSessionsPerClient` instead removes the per-client bound for everyone behind that key.
**Memory:** an idle session costs about 22 KB plus 1.75 KB per registered tool of heap, so a
handler at its 1000-session cap holds about 22 / 40 / 111 MB with 0 / 10 / 50 tools. That is a
floor: open GET streams add a socket each, and requests in flight add their own memory.
**Sessions live in the process.** Behind a load balancer with several instances, route by
`Mcp-Session-Id` (session affinity); a session that reaches another instance gets 404. A restart
ends every session.
**Shutting down:** the server factories' `close()` closes every session first (a GET whose
client stopped reading is destroyed after 5 s). With `createSecureHttpHandler`, call
`handler.close()` before your server's `close()`; `server.shutdown()` closes every attached
handler's sessions too. `server.close()` and `server.isConnected()` describe the stdio
connection only — they are not a readiness signal for HTTP.
#### Sessions end; hosts must recover
Every way a session ends reaches its client as 404: DELETE, the idle sweep, the per-client
eviction above, a request that failed with 500, a reclaimed `initialize`, a restart. A host must
then re-initialize — the protocol requires it, and stock SDK clients do not do it by themselves —
and:
- **discard the old session id.** A transport rebuilt with a stored `sessionId` resends it, skips
`initialize`, and gets 404 again, indefinitely (the server does not throttle 404s);
- **not replay blindly a call that was in flight.** It may already have run.
Hosts differ. Claude Code re-initializes on 404 by itself (leaving up to two orphaned sessions
per recovery, which the idle sweep and the per-client eviction reclaim). The MCP Inspector does
not; reconnect it by hand. Neither sends DELETE when it ends, so their sessions end by the idle
time. A host that creates clients and never closes them holds their sessions, and their per-key
slots, until its process exits.
#### Registrations freeze when a handler is attached
`createSecureHttpHandler`, `createHttpServer` and both server factories **freeze the server's
registrations** when they return. Register every tool, resource and prompt first. After that,
every registration method, `registerCapabilities()`, and every call or field write through a
returned handle (`update`, `disable`, `enable`, `remove`) throws `LateRegistrationError` and
changes nothing — on stdio too, for the same server object. Each session replays the frozen set;
a change after the first session would reach some clients and not others.
- **Dynamic tools are not supported once a handler is attached.** A tool that toggles another at
runtime surfaces as a tool error to its client, and the operator gets a
`MCP_SECURE_SERVER_LATE_REGISTRATION` warning (counted at 1, 10, 100, …).
- **A kill switch is a flag checked inside the tool's callback.** Catching
`LateRegistrationError` around `disable()` leaves the tool callable.
- **Rebuilding the server to change its tools** resets its Layer 3 window and empties Layer 4's
chains and quotas.
- **Escape hatches stay on stdio.** A request handler set through `server.server`, or a tool
registered on `server.mcpServer`, reaches the stdio connection only, never an HTTP session, so
neither is a way around the freeze (the second also skips Layer 5's response wrapping). Reading
either getter on a server with an HTTP handler warns once (`MCP_SECURE_SERVER_ESCAPE_HATCH`).
- **A registration that throws** (a duplicate name, say) can leave the stdio-side server partly
changed — the SDK's behaviour. HTTP sessions never see the partial change: only registrations
that succeed are recorded.
- Instances passed in `options.server` (a task store, for example) are shared by every session's
server.
#### Inside a tool: the calling session
A tool handler's `extra` identifies the session that called it:
- **`extra.sendRequest(...)`** is how a tool asks *its* client for sampling or elicitation.
`server.server.createMessage()` goes to the stdio connection, which an HTTP-only server does not
have.
- **`server.sendLoggingMessage(params, extra.sessionId)`** reaches that session only, filtered by
its `logging/setLevel`. Without an id it goes to the stdio connection. An id that names no live
session — one that has ended, say — is delivered nowhere: falling back to the stdio client would
hand one user's message to another, past that client's level filter. Undeliverable messages warn
once (`MCP_SECURE_SERVER_HTTP_LOGGING`). It never broadcasts to every HTTP session, since
sessions may belong to different users.
- **`server.getClientCapabilities(extra.sessionId)`** and **`getClientVersion(extra.sessionId)`**
answer for that session's client; without an id, for the stdio client.
- Large sampling results need `maxBodySize` raised, which raises it for every POST.
- A client's responses to the server's requests are checked for shape (a JSON-RPC result or
error with an id) and metered at 600 per minute per session (`X-RateLimit-Scope: responses`);
their content is not inspected.
#### Browser clients: `Origin` and `Host`
A request carrying an `Origin` not in `allowedOrigins` gets 403; a request without `Origin`
(non-browser clients: Claude Code, SDK clients, curl) is allowed. The default list is empty, so
every browser origin is refused until you list it.
- **Exact serialized origins:** `scheme://host[:port]`, lowercase, no path, no trailing slash,
no default port. A malformed entry throws at construction. `"null"` cannot be listed: it is the
origin of every sandboxed frame and `file://` page, so listing it would admit them all.
- **Local development:** list the dev server's origin exactly as the browser sends it —
`http://localhost:5173` and `http://127.0.0.1:5173` are different origins.
- **Electron renderers, browser extensions and webviews send `Origin`** and must be listed. A
context with an opaque origin (a renderer on `file://`) sends `Origin: null` and cannot be
listed: connect from Electron's main process, which sends no `Origin`, or serve the renderer
from a listed origin.
- **`allowedHosts`** adds an exact `Host` check: defence against DNS rebinding where a proxy has
stripped `Origin`. It does not restrict non-browser clients, which can send any `Host`. `[]`
throws.
**CORS** is yours to add, in a wrapper in front of the handler. Echo the request's `Origin` only
when it is listed (never `*`, never `null`), pass the same list as `allowedOrigins`, allow
`GET, POST, DELETE, OPTIONS` and the `Mcp-Session-Id` request header, and expose
`Mcp-Session-Id` so a page can read it. **The wrapper must never delete or rewrite `Origin`**:
the handler validates it, and a wrapper that strips it turns the check off for every page on the
machine. The [`http-session-server`](cookbook/http-session-server/src/server.ts) and
[`multi-endpoint-server`](cookbook/multi-endpoint-server/src/app.ts) examples carry a complete
wrapper.
#### Warnings
The package reports conditions that are not errors through one sink per server:
| Code | When |
|---|---|
| `MCP_SECURE_SERVER_HTTP_EXPERIMENTAL` | once per process, when a handler is created |
| `MCP_SECURE_SERVER_HTTP_EXPOSED` | once per handler, when its listener is bound beyond loopback |
| `MCP_SECURE_SERVER_LATE_REGISTRATION` | a registration or handle change after the freeze (counted at 1, 10, 100, …) |
| `MCP_SECURE_SERVER_ESCAPE_HATCH` | `server.server`, `server.mcpServer` or `server.experimental` was used on a server with an HTTP handler (a use before attaching is reported when it attaches) |
| `MCP_SECURE_SERVER_HTTP_LOGGING` | `sendLoggingMessage` was given an id that names no live session, or had no id and no stdio connection (each once) |
| `MCP_SECURE_SERVER_TRUST_PROXY` | a `trustProxy` misconfiguration a request revealed (see below) |
By default they go to `process.emitWarning`, which `NODE_NO_WARNINGS=1` hides. In production,
route them to your logger, and silence a code only by naming it:
```typescript
const server = new SecureMcpServer({ name: 'my-server', version: '1.0.0' }, {
onWarning: (message, code) => logger.warn({ code }, message),
silenceWarnings: ['MCP_SECURE_SERVER_HTTP_EXPERIMENTAL'] // each code named; there is no "all"
});
```
An unknown code in `silenceWarnings`, or an `onWarning` that is not a function, throws at
construction.
#### What is per client, and what is not
**Layer 3 is shared by every client of a server until 0.2.0.** One client can exhaust the
request budget for all, and Layer 3 counts every message: a short client is three (`initialize`,
`notifications/initialized`, one call). Under the `standard` preset (a burst of 10 per 10 s),
three clients arriving together fit and the fourth is refused. Hosts must back off, with jitter,
and retry an `initialize` answered 429; nothing in 0.1.0 rewards the host that backs off over the
one that retries first. A 429 on `notifications/initialized` leaves a session its client
abandons; it holds a per-key slot until it has been idle 60 s (when the per-client eviction can
take it) or until the idle time. **For several clients over HTTP, raise Layer 3's limits
(`maxRequestsPerMinute`, `burstThreshold`) or use the `basic` preset** until 0.2.0. Layer 4's
per-tool quotas are shared the same way. The opt-in Layer 5 per-client limit
(`contextual.rateLimiting`) does not prevent either: it runs after Layers 3 and 4 have counted
the request. Only a per-client limiter in front of the server (at your proxy) bounds one client
today.
**What is per client** (keyed on the rate-limit key, below): the session cap and its eviction,
the `sessionlessRequests*` GET/DELETE limits, and the **error lockout**. The lockout throttles
repeated malformed requests from a key — their refusals become 429s. It never refuses a
well-formed request, so it is not a per-client budget.
**Telling what happened:**
| Status | `X-RateLimit-Scope` | Meaning |
|---|---|---|
| 429 | `errors` | the error lockout: this key sent too many malformed requests |
| 429 | `sessionless` | this key's GET/DELETE limit |
| 429 | `not-found` | this key's 404s on paths other than the endpoint (server factories) |
| 429 | `responses` | this session's client sent more than 600 responses a minute |
| 503 | `sessions` | the handler's session cap, a reclaimed `initialize`, or shutdown |
| 503 | `client-sessions` | this key's session cap — see `trustProxy` first |
A 429 **without** that header came from the validation pipeline (Layer 3 or 4, or the Layer 5
per-client limit — the error's `data.reason` says which); `trustProxy` affects only the
transport's.
#### Client IP and `trustProxy`
Every per-client limit on the HTTP path — the per-client session cap and its eviction, the error
lockout, the 404 lockout, the `sessionlessRequests*` ceilings, and the opt-in Layer 5 rate limit
— keys on one value resolved from the connection: the client's **rate-limit key**. It is derived from an address (see *Keys*),
but it is not one. `X-Forwarded-For` and `X-Real-IP` are written by whoever sends the request, so
they are only read through hops you tell the server to trust:
| `trustProxy` | Key derived from | Use when |
|---|---|---|
| `0` (default) | the TCP peer; forwarding headers ignored | the server is exposed directly |
| `['10.0.0.12', '10.0.0.13']` (addresses or CIDRs of **your proxies**) | walking `X-Forwarded-For` right to left from the peer, the first address **not** in the list | **preferred** behind proxies whose addresses you know |
| `N` (hop count) | the Nth `X-Forwarded-For` entry from the right | proxy addresses are not stable and every request crosses exactly N appending proxies |
```typescript
// nginx on 10.0.0.12 in front of the app
server.createHttpServer({ endpoint: '/mcp', trustProxy: ['10.0.0.12'] });
```
**Every address in the list is treated as a proxy.** A request *from* a listed address is keyed
on the `X-Forwarded-For` entries it sends, so the list must contain only your proxies. A request
from an address **not** in the list is keyed on that address, however many entries it forges.
The named ranges `'loopback'`, `'linklocal'` and `'uniquelocal'` (all of 10/8, 172.16/12,
192.168/16 and fc00::/7) are safe **only when no client can reach the server from those ranges**.
On an intranet, VPN or container network, clients connect from private addresses, and a range
like `'uniquelocal'` lets each of them choose its own key. List your proxies' exact addresses,
or the narrowest CIDR that contains only them.
**The hop-count form trusts the topology blindly.** It trusts every direct peer, so use it only
for a server no client can reach except through exactly that many appending proxies. If the app port is reachable without going
through the proxy, or `N` is higher than the real number of appending proxies, clients can choose
their key again. A request carrying fewer entries than `N` is keyed on the TCP peer, never on the
leftmost entry.
**Preconditions either form relies on:**
- **Your proxy must append to `X-Forwarded-For`**, not pass the client's value through. nginx:
`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` (not `$http_x_forwarded_for`).
A proxy that sets only `X-Real-IP` gives the server nothing it can trust; requests are keyed
on the proxy. No server setting can recover a client address the proxy never recorded.
- **Count every appending hop.** Cloudflare in front of an ALB is two hops, not one. An
undercount keys clients on a CDN edge address, so users routed through one edge share a budget,
**and it is silent** — no warning fires.
- **The listener needs a TCP peer.** Behind a proxy connecting over a Unix socket, the server sees
no peer address: every request is keyed `'unknown'` (one shared budget), `trustProxy` has no
effect, and nothing warns.
**Keys.** The key is the client address after normalization: ports and brackets stripped
(`203.0.113.1:5555` and `[2001:db8::1]:443` become bare addresses), IPv4-mapped IPv6
(`::ffff:203.0.113.1`) treated as IPv4, and an entry that is not an IP replaced by the nearest
trusted hop. **IPv6 clients are keyed by their /64**, the smallest block a subscriber is routinely
assigned — keying each address would let one client rotate through 2^64 keys. The /64 is not one
client in either direction: a holder of a /56 or /48 controls 256–65,536 keys, and clients behind
one NAT or CGNAT address share one key. Tracked keys are capped at 10,000 per limiter window
(oldest evicted). Because the value is a key (`2001:db8:0:0::/64`, or `'unknown'` without a peer
address), do not use it for allowlists, denylists or geolocation.
Each per-key 429 from the transport is logged with its key when security logging is on; the
scope header says which limit (see *Telling what happened* above).
Misconfiguration emits a one-time warning per handler (code `MCP_SECURE_SERVER_TRUST_PROXY`,
through the server's `onWarning`) in three cases: a **private-range** peer sends untrusted `X-Forwarded-For` (the message names everything those
clients then share: one session cap, one eviction pool, one GET/DELETE limit and one lockout); a request has fewer entries
than the hop count (raising `N` does not silence this one — only the correct value does); or a
trusted proxy sends none. It cannot detect NAT, a proxy with a **public** address that you have
not trusted, or an undercount, so no warning does not mean the configuration is right. The warnings
come from requests to the MCP endpoint; requests to other paths (404s) are keyed the same way but
do not warn. When building a resolver yourself (*HTTP Utilities*), `createClientIpResolver({ trustProxy, onWarning })`
takes its own sink: a function `(message, kind)` — `kind` is `'ignored'`, `'invalid'` or `'short'`,
each reported at most once per resolver — or `false` to silence that resolver. A handler reports
each kind once too, so one misconfiguration does not hide another.
`trustProxy: true` and malformed values throw `TypeError` when the server is created.
#### Factories
**Standalone function:**
```typescript
import { SecureMcpServer, createSecureHttpServer } from 'mcp-secure-server';
const server = new SecureMcpServer({ name: 'x', version: '1.0' }, {
toolRegistry: [{ name: 'echo', sideEffects: 'none' }]
});
server.tool('echo', 'Echo', { text: z.string() }, async ({ text }) => ({ content: [{ type: 'text', text }] }));
const httpServer = createSecureHttpServer(server, { endpoint: '/api/mcp' }); // freezes registrations
httpServer.listen(8080, '127.0.0.1');
```
**Multiple endpoints:**
For services exposing multiple MCP servers on different paths, use `createSecureHttpHandler` to compose your own routing:
```typescript
import { SecureMcpServer, createSecureHttpHandler } from 'mcp-secure-server';
import { createServer } from 'node:http';
// Create separate MCP servers with different tools/permissions
const adminServer = new SecureMcpServer({ name: 'admin', version: '1.0' }, {
toolRegistry: [{ name: 'delete-user', sideEffects: 'write' }]
});
const publicServer = new SecureMcpServer({ name: 'public', version: '1.0' }, {
toolRegistry: [{ name: 'get-status', sideEffects: 'none' }]
});
// Register tools on each server BEFORE creating its handler (creating it freezes registrations)
adminServer.tool('delete-user', ...);
publicServer.tool('get-status', ...);
// One session handler per server (validates requests, routes each to its session)
const adminHandler = createSecureHttpHandler(adminServer);
const publicHandler = createSecureHttpHandler(publicServer);
// Compose with custom routing
const httpServer = createServer(async (req, res) => {
if (req.url?.startsWith('/api/admin')) return adminHandler(req, res);
if (req.url?.startsWith('/api/public')) return publicHandler(req, res);
res.writeHead(404).end(JSON.stringify({ error: 'Not found' }));
});
httpServer.listen(3000, '127.0.0.1'); // no authentication: '/api/admin' is open to anything that reaches the port
// Shutting down: close every session, then the listener
await Promise.all([adminHandler.close(), publicHandler.close()]);
httpServer.close();
```
The [`multi-endpoint-server`](cookbook/multi-endpoint-server) example is this, with a CORS
wrapper and tests.
| Function | Purpose |
|----------|---------|
| `createSecureHttpServer` | Single endpoint, includes routing |
| `createSecureHttpsServer` | Same, with TLS (key/cert) — recommended for production |
| `createSecureHttpHandler` | Request handler only, you provide routing; returns a handler with `close()` and `sessionCount` |
**CORS:** a wrapper in front of the handler — see *Browser clients* above; it must never delete
or rewrite `Origin`.
**HTTPS:** Use the built-in factory (or deploy behind a TLS-terminating reverse proxy):
```typescript
import { readFileSync } from 'node:fs';
import { createSecureHttpsServer } from 'mcp-secure-server';
const httpsServer = createSecureHttpsServer(server, {
key: readFileSync('server.key'), // TLS private key (PEM string or Buffer)
cert: readFileSync('server.cert'), // TLS certificate (PEM string or Buffer)
ca: undefined, // optional CA chain
endpoint: '/mcp'
});
httpsServer.listen(3443, '127.0.0.1'); // TLS is not authentication
```
### Available Exports
```typescript
import {
SecureMcpServer, // Main secure server class
LateRegistrationError, // Thrown by a registration after an HTTP handler is attached
SecureTransport, // Transport wrapper
createSecureHttpServer, // HTTP server factory (single endpoint)
createSecureHttpHandler, // HTTP handler factory (multi-endpoint)
createSecureHttpsServer, // HTTPS server factory with TLS
ErrorRateLimiter, // Rate limiter for error responses
getClientIp, // Resolve a request's rate-limit key (one-off)
createClientIpResolver, // Build a validated rate-limit key resolver once
ContextualValidationLayer, // Layer 5 class
ContextualConfigBuilder, // Builder for Layer 5 config
createContextualLayer, // Factory for Layer 5
// Tool policy configuration
initializeToolPolicies, // Load config from object
resetToolPolicies, // Reset to defaults
registerToolPolicy, // Register policy at runtime
getToolPolicy, // Get policy for a tool
isRelaxedField, // Check if field has relaxed validation
getToolsByLevel, // List tools by security level
isValidSecurityLevel, // Validate security level string
defaultToolPolicies, // Default policy definitions
getToolPoliciesConfig, // Get current policies config
loadToolPoliciesConfig, // Load from file
matchesPattern, // Check if tool matches pattern
resolvePolicy, // Resolve policy for tool
ToolPolicyError, // Config error class
// Security presets
SECURITY_PRESETS, // All preset definitions
resolvePreset, // Get preset by name
getDefaultPreset, // Get default preset name
isValidPreset, // Validate preset name
// Type guards
isSeverity, // Type guard for Severity
isViolationType, // Type guard for ViolationType
isError, // Type guard for Error objects
getErrorMessage // Safe error message extraction
} from 'mcp-secure-server';
```
| Export | Description |
|--------|-------------|
| `SecureMcpServer` | Drop-in replacement for McpServer with 5-layer security |
| `LateRegistrationError` | Thrown by a registration or handle change after an HTTP handler is attached; carries `method` and `registrationName` |
| `SecureTransport` | Transport wrapper for message-level validation |
| `createSecureHttpServer` | HTTP server factory with security validation |
| `createSecureHttpHandler` | HTTP session handler for composing multi-endpoint servers; returns a `SessionHttpHandler` (`close()`, `sessionCount`) |
| `createSecureHttpsServer` | HTTPS server factory with TLS certificates |
| `ErrorRateLimiter` | Rate limiter for clients generating excessive errors |
| `getClientIp` | Resolve a request's rate-limit key (socket peer; `X-Forwarded-For` only through `trustProxy`); validates on every call |
| `createClientIpResolver` | Build a key resolver once, validating `trustProxy` at construction; emits misconfiguration warnings |
| `ContextualValidationLayer` | Layer 5 class for advanced customization |
| `ContextualConfigBuilder` | Builder for Layer 5 configuration |
| `createContextualLayer` | Factory function for Layer 5 with defaults |
| `initializeToolPolicies` | Load tool policies from config object |
| `resetToolPolicies` | Reset to default (empty) policies |
| `registerToolPolicy` | Register single tool policy at runtime |
| `getToolPolicy` | Get resolved policy for a tool name |
| `isRelaxedField` | Check if a field has relaxed validation for a tool |
| `getToolsByLevel` | List tools registered at a specific security level |
| `isValidSecurityLevel` | Validate if a string is a valid security level |
| `defaultToolPolicies` | Default policy definitions for tools |
| `getToolPoliciesConfig` | Get current tool policies configuration |
| `loadToolPoliciesConfig` | Load policies from JSON file |
| `matchesPattern` | Check if tool name matches a pattern (glob-style) |
| `resolvePolicy` | Resolve merged policy for a tool from config |
| `ToolPolicyError` | Error class for config validation failures |
| `SECURITY_PRESETS` | Object containing all preset definitions (basic, standard, paranoid) |
| `resolvePreset` | Get preset configuration by name |
| `getDefaultPreset` | Get the default preset name ('standard') |
| `isValidPreset` | Validate if a string is a valid preset name |
| `isSeverity` | Type guard to check if value is a valid Severity |
| `isViolationType` | Type guard to check if value is a valid ViolationType |
| `isError` | Type guard to check if value is an Error object |
| `getErrorMessage` | Safely extract error message from unknown value |
### Subpath Imports
Everything is available from the root import, but two subpaths exist for consumers who
want a narrower dependency surface (e.g. bundlers tree-shaking a transport-only usage):
```typescript
import { SecureMcpServer } from 'mcp-secure-server/server'; // server class only
import { SecureTransport } from 'mcp-secure-server/transport'; // transport layer only
```
Both carry their own `types` conditions, so they resolve under `moduleResolution:
node16`/`nodenext`.
### HTTP Utilities
`ErrorRateLimiter` and `createClientIpResolver` are the building blocks the HTTP factories use
internally — reach for them only when composing a custom handler around
`createSecureHttpHandler`. Build the resolver once, at startup, with the **same** `trustProxy`
you give the handler, so a bad value fails before the first request and both limiters agree on
who the client is:
```typescript
import { createServer } from 'node:http';
import { SecureMcpServer, createSecureHttpHandler, createClientIpResolver, ErrorRateLimiter } from 'mcp-secure-server';
const server = new SecureMcpServer({ name: 'my-server', version: '1.0.0' });
// ... register every tool here: creating the handler freezes registrations ...
const trustProxy = ['10.0.0.12']; // your proxy's address; one value, shared
const handler = createSecureHttpHandler(server, { trustProxy }); // (req, res) => Promise<void>, plus close()
const clientIp = createClientIpResolver({ trustProxy }); // throws here if trustProxy is invalid
const limiter = new ErrorRateLimiter(); // throttles clients spamming invalid requests
// Transport-level, client-specific. Not 400: clients send a well-formed request before they
// initialize (Claude Code probes with `server/discover`). Not 404: an expired session is not abuse.
const CLIENT_ERRORS = new Set([405, 408, 415]);
createServer(async (req, res) => {
const key = clientIp(req); // a rate-limit key, not an address; resolve before any await
if (limiter.shouldRateLimit(key)) { res.writeHead(429).end(); return; }
await handler(req, res);
// Count only rejections caused by this client. Pipeline 4xx/429s can come from the
// server-wide Layer 3/4 budgets; counting them would lock out every client at once.
if (CLIENT_ERRORS.has(res.statusCode)) limiter.recordError(key);
});
```
`ErrorRateLimiter` options (all optional):
| Option | Default | Meaning |
|---|---|---|
| `errorsPerMinute` | `20` | errors a key may record per minute before `shouldRateLimit` returns `true` |
| `errorsPerHour` | `100` | the same, per hour |
| `cleanupIntervalMs` | `60000` | how often expired counters are swept |
| `maxTrackedClients` | `10000` | keys tracked per window; the oldest is evicted beyond this, bounding memory |
`getClientIp(req, { trustProxy })` is the one-off form: it re-validates on every call (so a bad
value throws per request) and never warns.
## Layer 5 Customization
Layer 5 is enabled by default. You can add custom validators at runtime for application-specific security rules.
### Adding Custom Validators
```typescript
import { SecureMcpServer } from 'mcp-secure-server';
const server = new SecureMcpServer(
{ name: 'my-server', version: '1.0.0' },
{
contextual: {
domainRestrictions: {
enabled: true,
blockedDomains: ['evil.com']
}
}
}
);
// Access Layer 5
const layer5 = server.validationPipeline.layers[4];
// Add custom validator with priority (lower = runs first)
layer5.addValidator('sensitive-data-check', (message, context) => {
if (message.params?.arguments?.creditCard) {
return {
passed: false,
reason: 'Credit card data not allowed in requests',
severity: 'HIGH',
violationType: 'SENSITIVE_DATA'
};
}
return { passed: true };
}, { priority: 50, failOnError: true });
```
**Exceptions fail closed.** Since 0.0.21, `failOnError` defaults to `true` for validators,
global rules and response validators: a validator that throws blocks the request (or the
response) with `VALIDATOR_ERROR`, matching Layers 1–4. Pass `failOnError: false` for an
advisory validator whose failure should not block. Before 0.0.21 the default was `false`, so
a throwing authorization or rate-limit validator silently admitted the request.
### Adding Global Rules
Global rules run before validators and can short-circuit validation.
```typescript
layer5.addGlobalRule((message) => {
// Block specific operations
if (message.method === 'admin/delete-all') {
return {
passed: false,
reason: 'Operation not permitted',
severity: 'CRITICAL',
violationType: 'POLICY_VIOLATION'
};
}
return null; // null = pass, continue to validators
});
```
### Adding Response Validators
Validate responses before they're sent to clients.
```typescript
layer5.addResponseValidator('pii-filter', (response) => {
const content = JSON.stringify(response);
// Check for SSN pattern
if (/\d{3}-\d{2}-\d{4}/.test(content)) {
return {
passed: false,
reason: 'PII detected in response',
severity: 'HIGH',
violationType: 'DATA_LEAK'
};
}
return { passed: true };
});
```
A response validator is called as `(response, request, context)`: `request` is
`{ tool, arguments }`, and `context.sessionId` is the calling HTTP session's id (a bearer
credential: hash it before logging), or `undefined` over stdio. *Until 0.1.0 `context` was always
`{}`, so a validator keyed on `context.sessionId` saw one shared key on every transport.*
### Using Context Store
Cross-request state management with TTL support.
> **`context.sessionId` is not an identity.** Over HTTP it is the session id the server issued
> (until 0.1.0 it was whatever `Mcp-Session-Id` the client sent, or `'stateless'`); over stdio it
> is `undefined`. It names a session, not a user: there is no authentication, so anyone can start
> one, and the id is a bearer credential — whoever holds it is that session. Log a hash of it,
> never the id. Do not key authentication or authorization state on it. Key such state on an identity your own
> authentication established, and treat `context.clientId` (the HTTP rate-limit key) as a
> throttling key, not a user.
```typescript
// Set context with 5-minute TTL, keyed on an identity YOUR auth layer verified
layer5.setContext(`user:${verifiedUserId}`, {
authenticated: true,
roles: ['admin']
}, 300000);
// Get context
const user = layer5.getContext(`user:${verifiedUserId}`);
// Use in validators — resolveVerifiedUser is yours (e.g. from a validated token), not a header
layer5.addValidator('auth-check', (message, context) => {
const session = layer5.getContext(`user:${resolveVerifiedUser(context)}`);
if (!session?.authenticated) {
return {
passed: false,
reason: 'Authentication required',
severity: 'HIGH',
violationType: 'AUTH_REQUIRED'
};
}
return { passed: true };
});
```
### Disabling Layer 5
```typescript
const server = new SecureMcpServer(
{ name: 'my-server', version: '1.0.0' },
{ contextual: { enabled: false } }
);
```
## Security Features
See [SECURITY.md](https://github.com/aself101/mcp-secure-server/blob/main/SECURITY.md) for full security documentation including:
- Attack detection coverage (injection, XSS, SSRF, deserialization, etc.)
- Security best practices applied
- SSRF protection details
- Error sanitization
- Reporting vulnerabilities
## Attack Coverage
The framework detects and blocks 200+ attack patterns across 19 categories including injection attacks, path traversal, SSRF, deserialization, and more.
See [SECURITY.md](https://github.com/aself101/mcp-secure-server/blob/main/SECURITY.md#attack-vectors) for the complete threat model with:
- Attack vectors and examples
- Severity levels and detection layers
- Mitigation strategies
- Violation type reference
## Error Handling
### Error Message Behavior
Error messages are **deterministic and type-specific** — each violation type maps to a consistent, descriptive message. This makes errors diagnosable from logs, MCP clients, and LLM tool-use contexts.
| `enableDetailedErrors` | `error.message` | `data.reason` |
|------------------------|-----------------|---------------|
| `false` (default) | Type-specific (e.g. "Request validation failed") | Redacted internal reason |
| `true` | Type-specific + redacted reason appended | Redacted internal reason |
When `enableDetailedErrors` is `true`, the `error.message` field includes the redacted reason so the full diagnostic is visible without digging into the `data` envelope:
```json
{
"error": {
"code": -32602,
"message": "Request validation failed: Content validation — path traversal pattern detected in parameter 'file_path'",
"data": {
"reason": "Content validation — path traversal pattern detected in parameter 'file_path'",
"layer": "VALIDATION_ERROR"
}
}
}
```
When `enableDetailedErrors` is `false`, the top-level message is the category only — the detailed reason is still available in `data.reason`:
```json
{
"error": {
"code": -32602,
"message": "Request validation failed",
"data": {
"reason": "Content validation — path traversal pattern detected in parameter 'file_path'",
"layer": "VALIDATION_ERROR"
}
}
}
```
### Validation Error Structure
When the security pipeline blocks a request, the framework returns a JSON-RPC error with diagnostic context in the `data` field:
| Field | Description |
|-------|-------------|
| `timestamp` | When the error occurred (ISO 8601) |
| `token` | Unique error token for log correlation |
| `reason` | Redacted validation reason describing what failed and why |
| `layer` | Which validation layer or violation type triggered the block |
| `retryAfterMs` | Present only for `RATE_LIMIT_EXCEEDED` — milliseconds to wait |
The `reason` field is sanitized through the same credential/PII redaction pipeline used for logging, so it is safe to surface to clients while still providing actionable diagnostics.
### Error Codes
| Code | Violation Type | Meaning |
|------|---------------|---------|
| `-32602` | `VALIDATION_ERROR`, `POLICY_VIOLATION`, `CONTEXT_VIOLATION` | Invalid input or policy block |
| `-32000` | `RATE_LIMIT_EXCEEDED` | Too many requests — check `retryAfterMs` |
| `-32603` | `INTERNAL_ERROR` | Internal validation error |
### Severity Levels
| Severity | Description | Action |
|----------|-------------|--------|
| `CRITICAL` | Active exploit attempt (command injection, deserialization) | Block + Alert |
| `HIGH` | Serious attack (SQL injection, path traversal) | Block |
| `MEDIUM` | Suspicious activity (rate limit, size exceeded) | Block |
| `LOW` | Minor policy violation | Block or Warn |
### Outgoing Response Sanitization
On both the stdio connection and every HTTP session (HTTP since 0.1.0), the framework sanitizes outgoing JSON-RPC error responses that contain Zod validation patterns, with one important exception: **`-32602` (Invalid params) errors are preserved**. These errors describe the caller's input mistakes — field paths, expected types, and size limits — which are the input contract, not internal implementation details. Preserving them lets callers diagnose and fix their requests.
For non-`-32602` errors that contain Zod patterns (e.g., internal `-32603` errors), the framework replaces the response with a safe generic message:
```json
{
"error": {
"code": -32602,
"message": "Invalid input parameters",
"data": {
"reason": "Input failed schema validation (Zod). Check parameter types and required fields.",
"layer": "OUTGOING_SANITIZER"
}
}
}
```
This prevents internal Zod schema structures from leaking to clients while preserving actionable validation feedback on input errors.
### Nested Tool Input Errors
Version `0.0.22-security` expands serialized Zod 3 `unionErrors` and Zod 4 `errors`
when one union branch has a uniquely deeper field path. For example, an invalid
summary can name `analysis_summary.exploration_maps.0.metadata.framework` rather
than only `analysis_summary`. Equal-depth alternatives and malformed diagnostics
retain their original union message. Expansion stops at 20 nested union levels.
The rewrite preserves JSON-RPC IDs/error codes and `isError` tool-result envelopes.
This requires branch details to reach the sanitizer. MCP SDK 1.30 formats only
its top-level issues, so nested details can be lost before this package sees them.
For affected schemas, use the schema library's public error customization API to
include actionable nested paths in the union message, or an upstream formatter
that preserves branches. The sanitizer cannot reconstruct discarded information.
This release does not change tool schemas or how a host displays them.
### Type Guards for Error Handling
```typescript
import { isError, getErrorMessage, isSeverity } from 'mcp-secure-server';
try {
await server.connect(transport);
} catch (error) {
if (isError(error)) {
console.error('Error:', getErrorMessage(error));
}
}
// Validate severity values
const severity = 'HIGH';
if (isSeverity(severity)) {
// TypeScript knows severity is Severity type
}
```
## Claude Desktop Integration
Add to your Claude Desktop configuration (`claude_desktop_config.json`):
```json
{
"mcpServers": {
"secure-server": {
"command": "node",
"args": ["path/to/your/server.js"],
"cwd": "/path/to/project"
}
}
}
```
### Test Server
Run the included test server to verify the framework:
```bash
npm start
```
The test server includes 7 protected tools:
- `debug-calculator` - Basic math operations
- `debug-file-reader` - Safe file reading
- `debug-echo` - Text echo service
- `debug-database` - Database query simulation
- `debug-http` - HTTP request simulation
- `debug-parser` - JSON/XML parsing
- `debug-image` - Image processing simulation
Add to Claude Desktop:
```json
{
"mcpServers": {
"secure-test": {
"command": "npx",
"args": ["tsx", "cookbook/minimal-server/minimal-test-server.ts"],
"cwd": "/path/to/mcp-secure-server"
}
}
}
```
## Development
### Running Tests
```bash
# Run all tests
npm test
# Run specific test suites
npm run test:unit
npm run test:integration
npm run test:performance
# Typecheck + full suite against the newest published MCP SDK 1.x, in a scratch copy
# (npm test runs the locked version; this repo has no CI to run the second column)
npm run test:sdk-latest # or: npm run test:sdk-latest -- 1.31.0
# Cookbook examples (needs `cd cookbook && npm install` once; part of prepublishOnly)
npm run test:cookbook # typecheck + tests for every example, offline
COOKBOOK_LIVE=1 npm run test:cookbook # also run tests that call live public APIs
# Watch mode for development
npm run test:watch
# Generate coverage report
npm run test:coverage
```
**Test Coverage:**
- Overall: 93.76% lines, 87.78% branches (`npm run test:coverage`)
- Unit, integration and type-level tests, including `test/integration/dist-smoke.test.ts`, which imports the compiled `dist/` in a separate `node` process
- Mutation tests for severity levels
- Boundary value tests for limits
- Real attack vector validation
### Running a Single Test
```bash
npx vitest run test/unit/utils/canonical.test.js
```
### Linting
```bash
npm run lint
```
### Project Structure
```text
src/
├── index.ts # Main entry point & public exports
├── types/ # TypeScript type definitions
│ ├── index.ts # Type exports & guards
│ ├── layers.ts # Layer type definitions
│ ├── messages.ts # MCP message types
│ ├── policies.ts # Policy type definitions
│ ├── server.ts # Server configuration types
│ └── validation.ts # Validation result types
└── security/
├── index.ts # Security module exports
├── mcp-secure-server.ts # SecureMcpServer class
├── constants.ts # Configuration constants
├── transport/
│ ├── index.ts # Transport exports
│ ├── secure-transport.ts # SecureTransport (the connect() connection)
│ └── http-server.ts # HTTP transport server
├── layers/
│ ├── validation-layer-base.ts # Base class for all layers
│ ├── layer1-structure.ts # JSON-RPC validation
│ ├── layer2-content.ts # Content/injection detection
│ ├── layer2-validators/ # Modular content validators
│ │ ├── index.ts # Validator exports
│ │ ├── pattern-detection.ts # Attack pattern matching
│ │ ├── base64-css.ts # Base64/CSS attack detection
│ │ └── data-semantics.ts # Data format validation
│ ├── layer3-behavior.ts # Rate limiting & burst detection
│ ├── layer4-semantics.ts # Tool contracts & policies
│ ├── layer5-contextual.ts # Custom validators
│ ├── contextual-config-builder.ts # Layer 5 fluent configuration
│ └── layer-utils/
│ ├── content/
│ │ ├── canonicalize.ts # Text normalization
│ │ ├── unicode.ts # Unicode attack normalization
│ │ ├── dangerous-patterns.ts # Pattern configuration
│ │ ├── helper-utils.ts # Content helper functions
│ │ ├── patterns/ # Attack pattern definitions
│ │ │ ├── index.ts # Pattern exports & utilities
│ │ │ ├── injection.ts # SQL/XSS/NoSQL patterns
│ │ │ ├── path-traversal.ts # Path traversal patterns
│ │ │ ├── network.ts # SSRF/network patterns
│ │ │ └── overflow-validation.ts # Buffer/encoding patterns
│ │ └── utils/
│ │ ├── index.ts # Utility exports
│ │ ├── text-decoding.ts # Encoding detection
│ │ ├── hash-utils.ts # Cache key generation
│ │ └── structural-analysis.ts # Deep structure analysis
│ └── semantics/
│ ├── semantic-policies.ts # Tool/resource policies
│ └── semantic-quotas.ts # Quota management
└── utils/
├── validation-pipeline.ts # Multi-layer orchestration
├── security-logger.ts # Security event logging
├── error-sanitizer.ts # Safe error responses
├── request-normalizer.ts # Request normalization
├── response-validator.ts # Response validation
└── tool-registry.ts # Tool management
cookbook/ # Example MCP servers
├── http-server/ # HTTP transport example
├── multi-endpoint-server/ # Multi-endpoint routing
├── image-gen-server/ # Image generation APIs
├── kenpom-server/ # Sports analytics API
├── nba-server/ # NBA statistics API
├── api-wrapper-server/ # Safe external API wrapper
├── database-server/ # SQL injection prevention
├── filesystem-server/ # Path traversal prevention
├── cli-wrapper-server/ # Command injection prevention
├── monitoring-server/ # Security metrics & alerts
├── transaction-server/ # State machine workflows
└── advanced-validation-server/ # Advanced security demos
```
## Troubleshooting
### Module Not Found
```text
Error: Cannot find module '@modelcontextprotocol/sdk'
```
**Solution:** `@modelcontextprotocol/sdk` is a regular dependency of this package, so this means a
broken install. Reinstall:
```bash
npm install mcp-secure-server
```
### Rate Limit Exceeded
```text
Error: Request blocked: Rate limit exceeded
```
**Solution:** Increase rate limits in configuration:
```typescript
{
maxRequestsPerMinute: 60,
maxRequestsPerHour: 1000
}
```
### Every Client Gets 429 Behind a Proxy
```text
HTTP 429 Too many requests (X-RateLimit-Scope: sessionless | errors | not-found)
HTTP 503 Too many sessions for this client (X-RateLimit-Scope: client-sessions)
Warning: X-Forwarded-For arrived from 10.0.0.5, a private address that trustProxy does not trust ...
```
**Solution:** since 0.0.26 per-client limits ignore `X-Forwarded-For` unless `trustProxy` names your
proxies, so everyone behind the proxy shares one budget. Set `trustProxy` — see
[Client IP and `trustProxy`](#client-ip-and-trustproxy). Raising the limits instead keeps the shared
budget and only delays the next lockout; raising `maxHttpSessionsPerClient` removes the
per-client session bound for everyone behind the proxy.
### False Positive Detection
```text
Error: Request blocked: Path traversal detected
```
**Solution:** If legitimate path contains `../`, configure resource policy:
```typescript
{
resourcePolicy: {
rootDirs: ['./allowed-paths'],
// Paths are validated relative to rootDirs
}
}
```
### Logging Not Working
```text
getVerboseSecurityReport() returns empty
```
**Solution:** Enable logging in configuration:
```typescript
{
enableLogging: true,
verboseLogging: true
}
```
### Log Files Missing or Empty (logging enabled)
```text
enableLogging: true, but no log files appear / getVerboseSecurityReport is empty
```
**Cause:** The logger silently degrades to no-op when the log directory cannot
be created or written — e.g. MCP hosts that launch the server with `cwd="/"`,
where the default `<cwd>/logs` resolves to the unwritable `/logs`.
**Diagnose:** Check `getSecurityStats().logger.fileLoggingAvailable`. If `false`,
logging is degraded; a one-time warning was also written to stderr at startup.
`writeErrors`/`lastWriteError` flag failures that began after a healthy start.
**Solution:** Point `logDir` at a writable path (or set the `LOG_DIR` env var):
```typescript
{
enableLogging: true,
logDir: '/var/log/my-mcp-server' // or set LOG_DIR=/var/log/my-mcp-server
}
```
If the directory still cannot be created, security events are not recorded but
the server continues operating.
### Layer 5 Validators Not Running
**Solution:** Ensure Layer 5 is enabled:
```typescript
{
contextual: {
enabled: true // Must be true (default)
}
}
```
### TypeScript Type Errors
**`tsc` very slow or out of memory around `server.tool(...)` calls:** two copies of `zod` are
installed (see [TypeScript Support](#typescript-support)). Run `npm ls zod` and align on one version.
**`No overload matches this call` on `server.tool(...)`:** since 0.0.24-security the handler's
return type is checked against the SDK's `CallToolResult`. A result declared as an `interface`
fails (interfaces have no implicit index signature) — declare it with `type` instead, or annotate
the handler's return as `CallToolResult`.
**Otherwise:** Ensure you're using TypeScript 5.0+ with strict mode:
```json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
```
### SQL Injection Detected (False Positive)
```text
Error: Request blocked: SQL injection detected
```
**Cause:** Content contains SQL-like keywords (SELECT, DROP, UNION) in legitimate text.
**Solutions:**
1. Use tool policies to relax validation for content storage tools:
```json
{
"version": "2.0",
"tools": {
"save_document": {
"level": "STORAGE",
"relaxedFields": ["content", "description"]
}
}
}
```
2. Set the tool's security level programmatically:
```typescript
import { registerToolPolicy } from 'mcp-secure-server';
registerToolPolicy('save_document', {
level: 'STORAGE',
relaxedFields: ['content']
});
```
### Command Injection Detected (False Positive)
```text
Error: Request blocked: Command injection detected
```
**Cause:** Content contains shell characters (|, ;, &&, backticks) or common command names (top, env, curl, grep) in legitimate text.
**Note:** As of v0.0.14, STORAGE-level tools no longer check `command.systemInfo`, `command.basicInjection`, `command.networkOperations`, or `command.fileOperations` patterns — only `command.shellAccess` and `command.executionWrappers` (which match actual shell invocations like `bash -i`, `/bin/sh`, `system()`). If you're seeing this on a STORAGE-level tool, upgrade to v0.0.14+.
**Solution:** Use `STORAGE` level for content tools:
```typescript
registerToolPolicy('save_code_snippet', {
level: 'STORAGE',
relaxedFields: ['code']
});
```
### XSS Attempt Detected (False Positive)
```text
Error: Request blocked: XSS attempt detected
```
**Cause:** Content contains HTML/script tags in legitimate documentation.
**Solution:** Use `DISPLAY` level for read-only display tools:
```typescript
registerToolPolicy('render_markdown', {
level: 'DISPLAY'
});
```
### Semantic Validation: Missing Required Parameter
```text
Error: Request blocked: Missing required parameter
```
**Cause:** Tool is registered in `toolRegistry` with `argsShape` that doesn't match the request.
**Solution:** Verify tool schema matches expected parameters:
```typescript
{
toolRegistry: [{
name: 'my-tool',
argsShape: {
query: { type: 'string', required: true },
limit: { type: 'number' } // optional
}
}]
}
```
### Semantic Validation: Tool Not Registered
```text
Error: Request blocked: Tool not registered
```
**Cause:** Tool called via MCP but not in server's `toolRegistry`.
**Solutions:**
1. Register all tools you expose:
```typescript
{
toolRegistry: [
{ name: 'tool-a', sideEffects: 'none' },
{ name: 'tool-b', sideEffects: 'read' }
]
}
```
2. There is no "allow all" setting. Unregistered tools are always denied at Layer 4
(`TOOL_NOT_ALLOWED`, fail closed). Omitting `toolRegistry` does not lift that — it
installs the built-in registry, which contains only `debug-calculator`,
`debug-file-reader` and `debug-echo`. *(Until 0.0.21 this section claimed omission
allowed all tools; it never did.)*
### Message Size Exceeded
```text
Error: Request blocked: Message size exceeds limit
```
**Solution:** Increase message size limit:
```typescript
{
maxMessageSize: 100000 // 100KB (default: 50KB)
}
```
### String Parameter Too Long
```text
Error: Request could not be processed: String parameter too long: 9213 chars (max: 5000)
```
**Cause:** A single string argument exceeds the per-string cap (default: 5,000 chars). Common for tools that legitimately accept long text payloads — report markdown, document bodies, large descriptions.
**Solution:** Raise the per-string limit, and keep `maxMessageSize` above it — the message envelope is larger than the string it carries, so the size check fires first if you raise only the string cap:
```typescript
{
maxStringLength: 128 * 1024, // 128K chars (default: 5,000)
maxMessageSize: 500 * 1024 // envelope headroom above maxStringLength
}
```
Requires `mcp-secure-server >= 0.0.17-security` — earlier versions hardcode the default.
### Burst Activity Detected
```text
Error: Request blocked: Burst activity detected
```
**Cause:** Too many requests in the burst detection window.
**Solutions:**
1. Increase burst threshold:
```typescript
{
burstThreshold: 20 // Max requests in window (default: 10)
}
```
2. Extend the burst window:
```typescript
{
burstWindowMs: 15000 // 15 seconds (default: 10000)
}
```
3. Use the `basic` preset for development:
```typescript
{
securityLevel: 'basic' // 30 burst threshold, 10s window
}
```
### Automated Timing Pattern Detected
```text
Error: Request blocked: Automated timing pattern detected
```
**Cause:** Requests are arriving at suspiciously consistent intervals, suggesting automation.
**Solutions:**
1. Disable automation detection (for legitimate automation):
```typescript
{
automationDetection: { enabled: false }
}
```
2. Adjust detection thresholds:
```typescript
{
automationDetection: {
enabled: true,
maxVariance: 100, // Allow more timing variance (default: 50ms)
sampleSize: 10 // Require more samples (default: 5)
}
}
```
3. Use the `basic` preset (automation detection disabled):
```typescript
{
securityLevel: 'basic'
}
```
### Suspiciously Large Message
```text
Error: Request blocked: Suspiciously large message
```
**Cause:** Message exceeds the suspicious size threshold.
**Solution:** Increase the threshold:
```typescript
{
suspiciousMessageSize: 50000 // 50KB (default: 20KB)
}
```
## License
MIT License - see [LICENSE](LICENSE) file for details.
---
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for version history and release notes.
---
**Disclaimer:** This framework provides defense-in-depth security but cannot guarantee protection against all attacks. Always follow security best practices and keep dependencies updated.
TDQS
A3.5/5.0
Scored across 7 tools
Disambiguation5/5
Each tool has a distinct domain (calculator, database, echo, file reading, HTTP, image processing, parsing) with no functional overlap, making them easy to distinguish.
Naming Consistency5/5
All tools follow a consistent 'debug-<noun>' pattern with hyphens, providing a clear and predictable naming convention.
Tool Count5/5
Seven tools is well-scoped for a debugging/testing framework, covering essential operations without excess.
Completeness5/5
The tools cover a broad range of typical debugging scenarios (arithmetic, database, file I/O, HTTP, image processing, parsing) with safety restrictions, leaving no obvious gaps.
Maintenance
ActivityActive
ResponsivenessNo issues