fixing jsonSchema validation by using zod
This commit is contained in:
@@ -297,3 +297,462 @@ Proceed to Phase 1: Design & Contracts
|
||||
- Define MCP tool contracts in contracts/
|
||||
- Generate quickstart.md with usage examples
|
||||
- Update agent context with technology decisions
|
||||
|
||||
---
|
||||
|
||||
## Remote Access Research (Added 2026-04-07)
|
||||
|
||||
This section documents research findings for remote MCP access requirements based on clarifications received after initial planning.
|
||||
|
||||
### Decision 8: Streamable HTTP Transport Implementation (MCP Specification Compliant)
|
||||
|
||||
**Decision**: Use MCP SDK's `StreamableHTTPServerTransport` over HTTP/1.1 with Server-Sent Events (SSE)
|
||||
|
||||
**Question**: How to implement remote transport for MCP SDK per official specification?
|
||||
|
||||
**Investigation Findings**:
|
||||
|
||||
The MCP specification (2025-11-25) defines **Streamable HTTP** as the standard remote transport. The MCP SDK (@modelcontextprotocol/sdk v1.0.4) provides official transport implementations:
|
||||
1. **StdioServerTransport** - stdio for local process communication
|
||||
2. **StreamableHTTPServerTransport** - Remote HTTP/1.1 using SSE (spec-compliant)
|
||||
3. **WebStandardStreamableHTTPServerTransport** - Platform-agnostic HTTP
|
||||
4. **SSEServerTransport** - Deprecated legacy transport
|
||||
|
||||
**Key Finding**: MCP's **Streamable HTTP** transport uses HTTP/1.1 with Server-Sent Events (SSE), NOT HTTP/2. The specification requires:
|
||||
- Single endpoint supporting POST, GET, and DELETE methods
|
||||
- POST for client→server messages (returns SSE stream or 202)
|
||||
- GET for server→client message stream (optional)
|
||||
- DELETE for explicit session termination
|
||||
- SSE for server→client streaming
|
||||
- Session management via `Mcp-Session-Id` header
|
||||
- Protocol version via `MCP-Protocol-Version` header (REQUIRED per clarification 2026-04-08)
|
||||
- Origin header validation for security
|
||||
- SSE polling pattern: Server sends initial event with ID and empty data, MAY close connection after response, clients reconnect using Last-Event-ID, server sends `retry` field before closing
|
||||
|
||||
**Integration Approaches Evaluated**:
|
||||
|
||||
**Option A: Native HTTP/1.1 + SSE (MCP Spec Compliant)** ⭐ SELECTED
|
||||
```
|
||||
Client → Node.js MCP Server (HTTP/1.1 + SSE via StreamableHTTPServerTransport)
|
||||
```
|
||||
- ✅ Direct implementation per MCP specification
|
||||
- ✅ Zero code complexity - use SDK's `StreamableHTTPServerTransport` directly
|
||||
- ✅ Single process deployment (no reverse proxy required for spec compliance)
|
||||
- ✅ Simplified debugging and local development
|
||||
- ✅ Meets all MCP security requirements (Origin validation, localhost binding)
|
||||
- ⚠️ Optional: Can add Nginx/Caddy for TLS termination and HTTP/2 upgrade (production enhancement)
|
||||
|
||||
**Option B: Reverse Proxy with HTTP/2 Upgrade**
|
||||
```
|
||||
Client (HTTP/2) → Nginx/Caddy (HTTP/2 → HTTP/1.1) → Node.js MCP Server (HTTP/1.1+SSE)
|
||||
```
|
||||
- ✅ Adds HTTP/2 multiplexing for client connections
|
||||
- ✅ TLS termination in reverse proxy
|
||||
- ⚠️ Adds deployment complexity
|
||||
- ⚠️ Not required for MCP specification compliance
|
||||
|
||||
**Rationale for Selection**:
|
||||
- MCP specification explicitly defines Streamable HTTP as HTTP/1.1 + SSE
|
||||
- HTTP/2 is an optional enhancement, not a requirement
|
||||
- Simpler deployment path (single Node.js process)
|
||||
|
||||
**Implementation Strategy**:
|
||||
```javascript
|
||||
// src/transports/streamable-http.js
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import http from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export class HTTPTransport {
|
||||
constructor(options = {}) {
|
||||
this.port = options.port || 3000;
|
||||
this.host = options.host || '127.0.0.1'; // Localhost for proxy
|
||||
|
||||
this.mcpTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
enableJsonResponse: false // Use SSE streaming
|
||||
});
|
||||
|
||||
this.server = http.createServer(async (req, res) => {
|
||||
// CORS, rate limiting, health check middleware
|
||||
// Then delegate to mcpTransport.handleRequest()
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Docker Configuration**:
|
||||
```yaml
|
||||
# docker-compose.yaml
|
||||
services:
|
||||
mcp-server:
|
||||
environment:
|
||||
TRANSPORT_MODE: http
|
||||
HTTP_PORT: 3000
|
||||
HTTP_HOST: 127.0.0.1 # Only accessible via nginx
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:8080" # External HTTP/2 port
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
|
||||
depends_on:
|
||||
- mcp-server
|
||||
```
|
||||
|
||||
**Nginx Configuration** (nginx/nginx.conf):
|
||||
```nginx
|
||||
server {
|
||||
listen 8080 ssl http2;
|
||||
server_name localhost;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||
|
||||
location /mcp {
|
||||
proxy_pass http://mcp-server:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# SSE support
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
chunked_transfer_encoding on;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://mcp-server:3000/health;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Alternatives**: Future enhancement can add native HTTP/2 option for single-binary deployment.
|
||||
|
||||
---
|
||||
|
||||
### Decision 9: MCP Session Management for Remote Access
|
||||
|
||||
**Decision**: Stateful sessions with Valkey backing, MCP Session ID mapped to Valkey session
|
||||
|
||||
**Question**: How should connection lifecycle and session management work for remote MCP?
|
||||
|
||||
**Key Distinction**: HTTP/2 Stream ≠ MCP Session
|
||||
- **HTTP/2 Stream**: Single request/response cycle, multiplexed over one TCP connection
|
||||
- **MCP Session**: Persistent identifier (`sessionId`) spanning multiple HTTP requests
|
||||
- **Valkey Session**: Business logic session tracking PNRs, searches, user context
|
||||
|
||||
**Session Lifecycle**:
|
||||
1. **Initialize**: Client connects → Transport generates sessionId (UUID) → Create Valkey session
|
||||
2. **Active**: Client makes requests with `MCP-Session-ID` header → Refresh session TTL
|
||||
3. **Idle**: No requests for N minutes → Session remains in Valkey (TTL not expired)
|
||||
4. **Expired**: TTL reaches zero → Valkey auto-deletes session data
|
||||
5. **Reconnect**: Client can resume with same `MCP-Session-ID` header
|
||||
|
||||
**Storage Pattern**:
|
||||
```
|
||||
session:{sessionId}:metadata → { createdAt, lastActivityAt, transportType, remoteIP }
|
||||
session:{sessionId}:searches → Recent search results (optional caching)
|
||||
session:{sessionId}:pnrs → Set of PNR codes created in this session
|
||||
pnr:{pnr} → Global PNR storage (not session-namespaced)
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
```javascript
|
||||
// src/session/manager.js
|
||||
async function handleToolCall(request, sessionId) {
|
||||
if (!sessionId) throw new Error('Session ID required');
|
||||
|
||||
let valkeySession = await sessionManager.getSession(sessionId);
|
||||
if (!valkeySession) {
|
||||
valkeySession = await sessionManager.createSession(sessionId);
|
||||
}
|
||||
|
||||
await sessionManager.updateActivity(sessionId);
|
||||
return await toolHandler(request.params, sessionId);
|
||||
}
|
||||
```
|
||||
|
||||
**Session Isolation**: Each session has isolated Valkey namespace. PNRs stored globally (separate from sessions).
|
||||
|
||||
---
|
||||
|
||||
### Decision 10: IP-Based Rate Limiting Algorithm
|
||||
|
||||
**Decision**: Sliding Window Counter (Hybrid Approach)
|
||||
|
||||
**Question**: What rate limiting algorithm works for IP-based tracking without authentication?
|
||||
|
||||
**Algorithms Evaluated**:
|
||||
1. **Fixed Window**: Simple but has burst problem (200 req in 1 second across window boundary)
|
||||
2. **Sliding Window Log**: Accurate but high memory (stores timestamp per request)
|
||||
3. **Sliding Window Counter**: Approximation balancing accuracy and performance ⭐ SELECTED
|
||||
4. **Token Bucket**: Good for burst allowance but complex state management
|
||||
|
||||
**Selected Algorithm**: Sliding Window Counter
|
||||
- ✅ Prevents large bursts (unlike fixed window)
|
||||
- ✅ Low memory (2 counters per IP)
|
||||
- ✅ Simple implementation (no Lua scripts required)
|
||||
- ✅ Accuracy within 1-2% of perfect sliding window
|
||||
|
||||
**Implementation**:
|
||||
```javascript
|
||||
// src/remote/ratelimit.js
|
||||
async function checkRateLimit(clientIP, limit = 100, windowSeconds = 60) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const currentWindow = Math.floor(now / windowSeconds);
|
||||
const previousWindow = currentWindow - 1;
|
||||
|
||||
const currentKey = `ratelimit:${clientIP}:${currentWindow}`;
|
||||
const previousKey = `ratelimit:${clientIP}:${previousWindow}`;
|
||||
|
||||
const [currentCount, previousCount] = await Promise.all([
|
||||
storage.incr(currentKey),
|
||||
storage.get(previousKey) || 0
|
||||
]);
|
||||
|
||||
if (currentCount === 1) {
|
||||
await storage.expire(currentKey, windowSeconds * 2);
|
||||
}
|
||||
|
||||
const elapsedInWindow = now % windowSeconds;
|
||||
const previousWeight = 1 - (elapsedInWindow / windowSeconds);
|
||||
const estimatedCount = (previousCount * previousWeight) + currentCount;
|
||||
|
||||
if (estimatedCount > limit) {
|
||||
throw new RateLimitError({ limit, current: Math.floor(estimatedCount) });
|
||||
}
|
||||
|
||||
return { allowed: true, remaining: limit - Math.floor(estimatedCount) };
|
||||
}
|
||||
```
|
||||
|
||||
**Performance**: ~3 Valkey ops per request, ~100 bytes per IP, within 1-2% of perfect sliding window.
|
||||
|
||||
**HTTP Headers**:
|
||||
```
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 45
|
||||
X-RateLimit-Reset: 1712486460
|
||||
Retry-After: 15
|
||||
```
|
||||
|
||||
**Client IP Extraction**:
|
||||
```javascript
|
||||
function getClientIP(request) {
|
||||
const forwarded = request.headers['x-forwarded-for'];
|
||||
if (forwarded) return forwarded.split(',')[0].trim();
|
||||
|
||||
const realIP = request.headers['x-real-ip'];
|
||||
if (realIP) return realIP;
|
||||
|
||||
return request.socket.remoteAddress;
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration**:
|
||||
```bash
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_PER_MINUTE=100
|
||||
RATE_LIMIT_WINDOW_SECONDS=60
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Decision 11: Global PNR Storage with TTL
|
||||
|
||||
**Decision**: Global namespace with SETEX, independent PNR lifecycle from sessions
|
||||
|
||||
**Question**: How to implement global PNR retrieval across sessions with configurable expiration?
|
||||
|
||||
**Requirements**:
|
||||
1. PNRs globally retrievable (any session can retrieve any PNR)
|
||||
2. PNRs expire after TTL (default 1 hour)
|
||||
3. PNR creation session logged but doesn't restrict retrieval
|
||||
4. Session expiration doesn't delete PNRs
|
||||
|
||||
**Storage Pattern**:
|
||||
|
||||
**Global PNR** (not session-scoped):
|
||||
```
|
||||
pnr:TEST-ABC123 → { pnr, status, segments, passengers, createdAt, expiresAt, creatingSessionId }
|
||||
```
|
||||
|
||||
**Session Reference** (for listBookings tool):
|
||||
```
|
||||
session:{sessionId}:pnrs → Set<pnr> // PNRs created in this session
|
||||
```
|
||||
|
||||
**Implementation**:
|
||||
```javascript
|
||||
// Create PNR
|
||||
async function createPNR(pnrData, ttlHours = 1) {
|
||||
const pnr = generatePNR(); // TEST-XXXXXX
|
||||
const ttlSeconds = ttlHours * 3600;
|
||||
|
||||
const pnrRecord = {
|
||||
pnr,
|
||||
status: 'confirmed',
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
|
||||
creatingSessionId: pnrData.sessionId, // For logging only
|
||||
...pnrData
|
||||
};
|
||||
|
||||
// Store globally with TTL
|
||||
await storage.setex(`pnr:${pnr}`, ttlSeconds, JSON.stringify(pnrRecord));
|
||||
|
||||
// Add to session's created PNRs list
|
||||
await storage.sadd(`session:${pnrData.sessionId}:pnrs`, pnr);
|
||||
|
||||
return pnrRecord;
|
||||
}
|
||||
|
||||
// Retrieve PNR (global, any session)
|
||||
async function retrieveBooking({ pnr }, sessionId) {
|
||||
const pnrData = await storage.get(`pnr:${pnr}`);
|
||||
if (!pnrData) {
|
||||
throw new NotFoundError(`PNR ${pnr} not found or expired`);
|
||||
}
|
||||
return JSON.parse(pnrData);
|
||||
}
|
||||
|
||||
// List PNRs created in session
|
||||
async function listBookings({ limit = 10 }, sessionId) {
|
||||
const pnrCodes = await storage.smembers(`session:${sessionId}:pnrs`);
|
||||
const pnrs = await Promise.all(
|
||||
pnrCodes.map(async (code) => {
|
||||
const data = await storage.get(`pnr:${code}`);
|
||||
return data ? JSON.parse(data) : null;
|
||||
})
|
||||
);
|
||||
return pnrs.filter(Boolean).slice(0, limit);
|
||||
}
|
||||
```
|
||||
|
||||
**Edge Cases**:
|
||||
1. **PNR Expired**: `retrieveBooking` returns "PNR not found or expired"
|
||||
2. **Session Expires Before PNR**: PNR remains globally retrievable
|
||||
3. **List After Session Expiry**: Returns empty (session reference deleted)
|
||||
|
||||
**Configuration**:
|
||||
```bash
|
||||
PNR_TTL_HOURS=1
|
||||
SESSION_TTL_HOURS=24
|
||||
```
|
||||
|
||||
**Storage Efficiency**: ~2KB per PNR, 1000 PNRs = 2MB, auto-cleanup via Valkey TTL.
|
||||
|
||||
---
|
||||
|
||||
### Decision 12: CORS Configuration
|
||||
|
||||
**Decision**: Permissive Wildcard CORS with Network-Level Access Control
|
||||
|
||||
**Question**: How to configure CORS for web-based MCP clients?
|
||||
|
||||
**CORS Policy**: `Access-Control-Allow-Origin: *` (wildcard)
|
||||
|
||||
**Rationale**:
|
||||
- ✅ Maximum compatibility - any web client can connect from any domain
|
||||
- ✅ Simplifies development - no origin whitelist configuration
|
||||
- ✅ Enables browser tools, Chrome extensions, web IDEs
|
||||
- ⚠️ Requires network-level security (firewall, VPN, private network)
|
||||
- ⚠️ Only acceptable for trusted development/testing environments
|
||||
|
||||
**Security Implications**:
|
||||
1. **No Credentials**: Wildcard incompatible with `Access-Control-Allow-Credentials: true` (acceptable - we have no auth)
|
||||
2. **Public Data**: Any website can make requests (acceptable - test data only)
|
||||
3. **CSRF Potential**: Limited risk (no authentication, state changes require valid PNR)
|
||||
4. **Network Security**: Deploy within private networks, use firewall rules
|
||||
|
||||
**Implementation**:
|
||||
```javascript
|
||||
// src/remote/cors.js
|
||||
export function applyCORS(req, res) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'false');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, MCP-Session-ID');
|
||||
res.setHeader('Access-Control-Expose-Headers', 'X-RateLimit-Limit, X-RateLimit-Remaining');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return true; // Handled
|
||||
}
|
||||
return false; // Continue
|
||||
}
|
||||
```
|
||||
|
||||
**Nginx Alternative**:
|
||||
```nginx
|
||||
location /mcp {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
|
||||
return 204;
|
||||
}
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
proxy_pass http://mcp-server:3000;
|
||||
}
|
||||
```
|
||||
|
||||
**Security Posture**: Wildcard CORS acceptable for mock server because:
|
||||
1. Contains only test data (no sensitive information)
|
||||
2. No authentication (no credentials to steal)
|
||||
3. Network-level access controls provide security boundary
|
||||
4. Maximizes developer flexibility for ad-hoc tooling
|
||||
|
||||
**Configuration**:
|
||||
```bash
|
||||
CORS_ENABLED=true
|
||||
CORS_ORIGINS=*
|
||||
CORS_MAX_AGE=86400
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Remote Access Technology Summary
|
||||
|
||||
| Component | Technology | Decision |
|
||||
|-----------|-----------|----------|
|
||||
| **HTTP/2 Server** | Nginx reverse proxy | Terminate HTTP/2, proxy to HTTP/1.1 |
|
||||
| **MCP Transport** | StreamableHTTPServerTransport | Over HTTP/1.1 (proxied) |
|
||||
| **Rate Limiting** | Sliding window counter | Valkey-backed, IP-based |
|
||||
| **PNR Storage** | Global with TTL | Valkey SETEX, independent lifecycle |
|
||||
| **CORS** | Wildcard policy | `Access-Control-Allow-Origin: *` |
|
||||
| **Health Check** | Unauthenticated endpoint | `/health` returning JSON status |
|
||||
|
||||
## Environment Variables (Remote Mode)
|
||||
|
||||
```bash
|
||||
# Transport
|
||||
TRANSPORT_MODE=stdio|http|both
|
||||
HTTP_PORT=3000
|
||||
HTTP_HOST=127.0.0.1
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_PER_MINUTE=100
|
||||
RATE_LIMIT_WINDOW_SECONDS=60
|
||||
|
||||
# PNR/Session
|
||||
PNR_TTL_HOURS=1
|
||||
SESSION_TTL_HOURS=24
|
||||
|
||||
# CORS
|
||||
CORS_ENABLED=true
|
||||
CORS_ORIGINS=*
|
||||
CORS_MAX_AGE=86400
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
All remote access research complete. Proceed to update:
|
||||
1. ✅ data-model.md - Add RemoteConnection, RateLimitRecord, HealthStatus entities
|
||||
2. ✅ contracts/ - Add health endpoint contract
|
||||
3. ✅ quickstart.md - Add remote access setup instructions
|
||||
|
||||
|
||||
Reference in New Issue
Block a user