Skip to content

Latest commit

 

History

History
472 lines (357 loc) · 14.2 KB

File metadata and controls

472 lines (357 loc) · 14.2 KB

Tool Layer

This document covers the MCP protocol implementation, transport options, session lifecycle, the five tools, the V8 sandbox execution model, and the api.request() contract.


MCP Protocol

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.

Streamable HTTP (default)

The AI client connects via HTTP POST to http://<host>:<port>/mcp.

Headers the client must send:

  • Content-Type: application/json
  • Accept: application/json, text/event-stream
  • Authorization: Bearer <token> — required on every request
  • mcp-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.

stdio

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.

Session state

Each session stores:

  • The raw bearer token from the Authorization header
  • A correlationId prefix 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).

Multi-pod deployments

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.


The Five Tools

Tool 1: discover_services

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.


Tool 2: discover_skills

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 }))
}

Tool 3: get_skill_details

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.


Tool 4: search_code

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 spec

The 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.


Tool 5: api_execute

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:

  1. Argument validation (method, path required)
  2. Permission guard check
  3. Circuit breaker check
  4. ConnectorAPI.request() → idempotency check → outbound HTTP
  5. Circuit breaker recordSuccess / recordFailure
  6. Audit collection

V8 Sandbox

The sandbox is the most unusual part of the codebase. It runs untrusted AI-generated code safely in an isolated V8 engine.

Why V8 isolation?

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.

Execution model

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

The api.request() bridge

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.

api.request() contract

// 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!

Sandbox limits

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

What sandbox code can and cannot do

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

Error Codes

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

Audit Logging

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.).


Observability

Log markers

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

Metrics

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.

Non-production caller info

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.