This document covers the MCP protocol implementation, transport options, session lifecycle, the five tools, the V8 sandbox execution model, and the api.request() contract.
The gateway implements Model Context Protocol — an open standard for connecting AI clients to tools and data sources. The protocol is JSON-RPC 2.0 over one of two transports.
The AI client connects via HTTP POST to http://<host>:<port>/mcp.
Headers the client must send:
Content-Type: application/jsonAccept: application/json, text/event-streamAuthorization: Bearer <token>— required on every requestmcp-session-id: <session-id>— required after initialization
Session lifecycle:
1. POST /mcp { method: "initialize" }
→ Response includes: mcp-session-id header + server capabilities
2. POST /mcp { method: "tools/list" } ← optional discovery
→ Returns the 5 tool definitions with input schemas
3. POST /mcp { method: "tools/call", params: { name: "...", arguments: {...} } }
→ Returns tool result (SSE stream or JSON, depending on Accept)
4. DELETE /mcp Header: mcp-session-id: <id> ← explicit logout
→ Session terminated immediately
Response format:
The gateway returns SSE (text/event-stream) when the client includes it in Accept. Each tool call returns one SSE event:
data: {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"..."}]}}
When the client accepts only application/json, the response is plain JSON.
The AI client spawns the gateway process and communicates via stdin/stdout.
# Direct launch
MCP_TRANSPORT=stdio node dist/index.js
# Via claude_desktop_config.json
{
"command": "node",
"args": ["/path/to/dist/index.js"],
"env": { "MCP_TRANSPORT": "stdio" }
}All structured logs go to stderr. Stdout is reserved for the MCP protocol stream.
Each session stores:
- The raw bearer token from the
Authorizationheader - A
correlationIdprefix for request tracing - Active sandbox isolates (for concurrent calls)
Sessions are evicted after SESSION_IDLE_TTL_MS (default: 1 hour) of inactivity. The sweep runs every SESSION_SWEEP_INTERVAL_MS (default: 5 minutes).
Sticky sessions are required. Session state (the McpServer instance and its tool registrations) lives in-process on the pod that created the session. A load balancer must route all requests carrying the same mcp-session-id header to the same pod — round-robin will cause random 404 UNKNOWN_SESSION errors as requests land on pods that have no record of the session.
If a pod dies, clients reconnect automatically. Because the bearer token lives in the shared token cache (TOKEN_CACHE_TYPE=memcache or couchbase), the reconnect is a fast cache hit — no re-login prompt for the user, just a new session initialized on a healthy pod.
No parameters. No sandbox.
Harbor validates the Authorization: Bearer <token> header on every request — including this one — before any tool logic runs. It checks RFC 6750 format: header presence, Bearer scheme, non-empty token, ≤ 8 KB, no control characters. Malformed or missing tokens receive a 401 immediately. This is Harbor's own protection layer; it does not call any backend auth endpoint.
For discover_services specifically, the RFC 6750 check is all that happens. The service catalog is read from the local registry — no introspection call is made. This prevents unauthenticated enumeration of your services while avoiding an unnecessary round-trip to your auth server for a read-only local operation.
{
"name": "discover_services",
"description": "List all registered backend services and their descriptions.",
"inputSchema": { "type": "object", "properties": {} }
}Response:
[
{ "service": "tasks", "description": "Task management service — manages tasks..." },
{ "service": "product", "description": "Product catalog — browsing, search..." }
]The AI reads descriptions and maps the user's intent to the right service.
Parameters: service (string), code (string — JavaScript function)
Runs AI-written code in a V8 sandbox with the service's skills array injected. No network access.
{
"name": "discover_skills",
"inputSchema": {
"type": "object",
"required": ["service", "code"],
"properties": {
"service": { "type": "string" },
"code": { "type": "string", "description": "async () => { ... }" }
}
}
}What's injected into the sandbox:
skills: Array<{
id: string // filename without extension — used as skill_id in get_skill_details
filename: string // full filename
title: string // title from frontmatter or first # heading
tags: string[] // tags array from frontmatter (empty array if none)
content: string // full markdown text including frontmatter
}>Example code:
async () => {
return skills
.filter(s => s.content.toLowerCase().includes('task'))
.map(s => ({ skill_id: s.id, title: s.title }))
}Parameters: service (string), skill_id (string)
Direct lookup — no sandbox, no network. Returns full Markdown content.
{
"name": "get_skill_details",
"inputSchema": {
"type": "object",
"required": ["service", "skill_id"],
"properties": {
"service": { "type": "string" },
"skill_id": { "type": "string" }
}
}
}Response: Raw Markdown text of the skill file.
Parameters: service (string), code (string — JavaScript function)
Runs AI-written code in a V8 sandbox with the service's OpenAPI spec injected. No network access.
{
"name": "search_code",
"inputSchema": {
"type": "object",
"required": ["service", "code"],
"properties": {
"service": { "type": "string" },
"code": { "type": "string" }
}
}
}What's injected:
spec: OpenAPIV3.Document // fully parsed, dereferenced OpenAPI specThe spec is deep-copied into the sandbox via ivm.ExternalCopy. The AI cannot modify the live spec.
Example code:
async () => {
return Object.entries(spec.paths)
.filter(([path]) => path.includes('task'))
.map(([path, methods]) => ({
path,
methods: Object.keys(methods).filter(m => m !== 'parameters')
}))
}Security property: The search sandbox has no api.request(), no network capability. It is a read-only computation over a JSON object.
Parameters: service (string), code (string — JavaScript function)
Runs AI-written code in a V8 sandbox with api.request() injected. api.request() is the only outbound surface.
{
"name": "api_execute",
"inputSchema": {
"type": "object",
"required": ["service", "code"],
"properties": {
"service": { "type": "string" },
"code": { "type": "string" }
}
}
}Every api.request() call goes through:
- Argument validation (method, path required)
- Permission guard check
- Circuit breaker check
ConnectorAPI.request()→ idempotency check → outbound HTTP- Circuit breaker
recordSuccess/recordFailure - Audit collection
The sandbox is the most unusual part of the codebase. It runs untrusted AI-generated code safely in an isolated V8 engine.
Normal eval() or new Function() share the Node.js process context — the AI code could access process.env, the filesystem, require(), or internal framework objects. isolated-vm creates a completely separate V8 engine with its own heap. Nothing leaks across the boundary except what is explicitly injected.
Host (Node.js process)
│
├── Creates ivm.Isolate({ memoryLimit: 64 }) ← brand new V8 engine
│
├── Creates context (empty global) ← no fetch, no require, nothing
│
├── Injects one thing via the bridge:
│ search_code: spec (ExternalCopy)
│ discover_skills: skills (ExternalCopy)
│ api_execute: __makeRequest (Reference to host function)
│ + bootstrap script defining api.request()
│
├── Compiles + runs user code ← runs in isolate
│
├── Result returned as JSON string across boundary
│
└── isolate.dispose() ← engine destroyed, all memory freed
For api_execute, the sandbox exposes exactly one function:
// Inside the sandbox (injected by bootstrap script):
const api = {
request: async (apiRequest) => {
const raw = await __makeRequest.apply(
undefined,
[JSON.stringify(apiRequest)],
{ arguments: { copy: true }, result: { promise: true, copy: true } }
)
const parsed = JSON.parse(raw)
if (parsed.__bridgeError) {
throw new Error(parsed.code + ': ' + parsed.message)
}
return parsed
}
}__makeRequest is a ivm.Reference to a host-side async function. Arguments cross the V8 boundary as JSON strings — no object references leak between contexts.
// Arguments
interface ApiRequest {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' // required
path: string // required, non-empty
params?: Record<string, unknown> // query string params
body?: unknown // request body (POST / PUT / PATCH)
headers?: Record<string, string> // extra headers merged with default headers
}
// Return value
interface ApiResponse {
data: unknown // parsed response body
status: number // HTTP status code
ok: boolean // true for 2xx/3xx; false for 4xx
}Critical: ok: false for 4xx is NOT an error. The function returns normally. Only 5xx (after retries exhausted) or policy violations throw.
// Correct pattern
const { data, status, ok } = await api.request({ method: 'GET', path: '/items' })
if (!ok) {
return { error: `API returned ${status}`, data }
}
return data
// Common mistake — body does not exist
const { body } = await api.request(...) // undefined!Configurable per global env:
| Variable | Default | Applies to |
|---|---|---|
SANDBOX_MEMORY_MB |
64 |
Both search and execute |
SANDBOX_EXECUTE_TIMEOUT_MS |
8000 |
api_execute only |
SANDBOX_SEARCH_TIMEOUT_MS |
3000 |
search_code and discover_skills |
Per-service overrides via config.json:
{
"sandbox": {
"memoryLimitMb": 128,
"executeTimeoutMs": 15000,
"maxApiCalls": 50,
"maxConcurrentCalls": 10
}
}| Limit | What it controls |
|---|---|
memoryLimitMb |
V8 isolate memory; isolate is disposed on OOM |
executeTimeoutMs |
Wall-clock timeout; SandboxTimeoutError thrown |
maxApiCalls |
Total api.request() calls per execution |
maxConcurrentCalls |
Simultaneous in-flight api.request() calls |
Can:
- Call
api.request()(execute sandbox only) - Read
spec(search sandbox only) - Read
skills(discover_skills sandbox only) - Use standard JavaScript (objects, arrays, async/await, promises)
- Use built-in globals (
JSON,Math,Date,console)
Cannot:
- Access
process,require,import,fetch,XMLHttpRequest - Access the Node.js event loop, timers, or I/O
- Access other services' specs, skills, or API clients
- Access framework internals or configuration
- Capture references across the V8 boundary
Errors from the sandbox and bridge are returned as structured objects in the tool response:
{
"error": "Human-readable message",
"code": "MACHINE_READABLE_CODE",
"retryable": true
}| Code | Retryable | When thrown |
|---|---|---|
SANDBOX_TIMEOUT |
Yes | Code exceeded the time limit |
SANDBOX_SYNTAX |
No | JavaScript syntax error in submitted code |
SANDBOX_MEMORY |
Yes | Isolate hit memory limit |
SANDBOX_EXECUTION |
No | Code threw an uncaught exception |
CALL_LIMIT_EXCEEDED |
No | Too many api.request() calls |
CONCURRENT_LIMIT_EXCEEDED |
No | Too many concurrent api.request() calls |
INVALID_API_REQUEST |
No | Bad method/path in api.request() argument |
CIRCUIT_OPEN |
Yes | Circuit breaker open for the endpoint |
PERMISSION_DENIED |
No | Token lacks permission for the endpoint |
API_ERROR |
No | Backend returned 5xx after retries |
Every api_execute call produces a structured audit record at info level:
{
"level": "info",
"type": "audit",
"auditId": "a1b2c3d4-...",
"service": "tasks",
"tool": "api_execute",
"authStrategy": "oauth-introspection",
"codeSubmitted": "async () => { const { data } = await api.request(...); return data; }",
"endpointsAccessed": ["GET /api/v1/tasks", "POST /api/v1/tasks"],
"apiCallCount": 2,
"durationMs": 312,
"outcome": "success",
"correlationId": "abc-123"
}Disable in development: ENABLE_AUDIT=false
Audit records are written to stderr as structured JSON and can be forwarded to any log aggregator (ELK, Splunk, CloudWatch, etc.).
| Marker | Meaning |
|---|---|
[MCP ▶ IN] |
Code/request received from AI |
[MCP ◀ OUT] |
Response sent to AI |
[MCP → API] |
Outbound request to backend |
[MCP ← API] |
Response from backend |
MetricsCollector tracks counters per service:
- Tool call counts by tool name and outcome
- Sandbox error counts by error type
- Circuit breaker open events
The MetricsCollector interface is pluggable — implement it to export to Prometheus, StatsD, or any backend.
In non-production environments (ENVIRONMENT not set to prod), every log line includes a caller field:
{ "level":"warn", "caller":"auth-middleware.ts:84", "msg":"Auth validation failed" }Powered by pino-caller. Automatically disabled in production.