llm-cli-gateway 2.12.0 → 2.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.js CHANGED
@@ -185,6 +185,93 @@ export function loadPersistenceConfig(logger = noopLogger) {
185
185
  sources,
186
186
  };
187
187
  }
188
+ export const DEFAULT_HTTP_MAX_SESSIONS = 100;
189
+ export const DEFAULT_HTTP_SESSION_IDLE_TTL_MS = 30 * 60 * 1000;
190
+ export const DEFAULT_HTTP_SESSION_REAPER_INTERVAL_MS = 60 * 1000;
191
+ export const DEFAULT_MAX_RUNNING_JOBS = 32;
192
+ export const DEFAULT_MAX_RUNNING_JOBS_PER_PROVIDER = 16;
193
+ export const DEFAULT_MAX_QUEUED_JOBS = 128;
194
+ export const DEFAULT_QUEUE_TIMEOUT_MS = 120000;
195
+ export const DEFAULT_COMPLETED_JOB_MEMORY_TTL_MS = 60 * 60 * 1000;
196
+ export const DEFAULT_MAX_JOB_OUTPUT_BYTES = 50 * 1024 * 1024;
197
+ const HttpSessionLimitsSchema = z
198
+ .object({
199
+ max_sessions: z.number().int().positive().default(DEFAULT_HTTP_MAX_SESSIONS),
200
+ session_idle_ttl_ms: z.number().int().positive().default(DEFAULT_HTTP_SESSION_IDLE_TTL_MS),
201
+ session_reaper_interval_ms: z
202
+ .number()
203
+ .int()
204
+ .positive()
205
+ .default(DEFAULT_HTTP_SESSION_REAPER_INTERVAL_MS),
206
+ })
207
+ .passthrough();
208
+ const JobLimitsSchema = z
209
+ .object({
210
+ max_running_jobs: z.number().int().positive().default(DEFAULT_MAX_RUNNING_JOBS),
211
+ max_running_jobs_per_provider: z
212
+ .number()
213
+ .int()
214
+ .positive()
215
+ .default(DEFAULT_MAX_RUNNING_JOBS_PER_PROVIDER),
216
+ max_queued_jobs: z.number().int().positive().default(DEFAULT_MAX_QUEUED_JOBS),
217
+ queue_timeout_ms: z.number().int().positive().default(DEFAULT_QUEUE_TIMEOUT_MS),
218
+ completed_job_memory_ttl_ms: z
219
+ .number()
220
+ .int()
221
+ .positive()
222
+ .default(DEFAULT_COMPLETED_JOB_MEMORY_TTL_MS),
223
+ max_job_output_bytes: z.number().int().positive().default(DEFAULT_MAX_JOB_OUTPUT_BYTES),
224
+ })
225
+ .strict();
226
+ export const DEFAULT_HTTP_SESSION_LIMITS = {
227
+ maxSessions: DEFAULT_HTTP_MAX_SESSIONS,
228
+ sessionIdleTtlMs: DEFAULT_HTTP_SESSION_IDLE_TTL_MS,
229
+ sessionReaperIntervalMs: DEFAULT_HTTP_SESSION_REAPER_INTERVAL_MS,
230
+ };
231
+ export const DEFAULT_JOB_LIMITS = {
232
+ maxRunningJobs: DEFAULT_MAX_RUNNING_JOBS,
233
+ maxRunningJobsPerProvider: DEFAULT_MAX_RUNNING_JOBS_PER_PROVIDER,
234
+ maxQueuedJobs: DEFAULT_MAX_QUEUED_JOBS,
235
+ queueTimeoutMs: DEFAULT_QUEUE_TIMEOUT_MS,
236
+ completedJobMemoryTtlMs: DEFAULT_COMPLETED_JOB_MEMORY_TTL_MS,
237
+ maxJobOutputBytes: DEFAULT_MAX_JOB_OUTPUT_BYTES,
238
+ };
239
+ export function loadLimitsConfig(logger = noopLogger) {
240
+ const configPath = defaultGatewayConfigPath();
241
+ const { parsed, sourcePath } = readGatewayTomlFile(configPath, logger, "limits");
242
+ const rawHttp = parsed?.http ?? {};
243
+ const rawLimits = parsed?.limits ?? {};
244
+ let httpParsed;
245
+ try {
246
+ httpParsed = HttpSessionLimitsSchema.parse(rawHttp);
247
+ }
248
+ catch (err) {
249
+ throw new Error(`Invalid [http] session-limit config: ${err instanceof Error ? err.message : String(err)}`);
250
+ }
251
+ let limitsParsed;
252
+ try {
253
+ limitsParsed = JobLimitsSchema.parse(rawLimits);
254
+ }
255
+ catch (err) {
256
+ throw new Error(`Invalid [limits] config: ${err instanceof Error ? err.message : String(err)}`);
257
+ }
258
+ return {
259
+ http: {
260
+ maxSessions: httpParsed.max_sessions,
261
+ sessionIdleTtlMs: httpParsed.session_idle_ttl_ms,
262
+ sessionReaperIntervalMs: httpParsed.session_reaper_interval_ms,
263
+ },
264
+ jobs: {
265
+ maxRunningJobs: limitsParsed.max_running_jobs,
266
+ maxRunningJobsPerProvider: limitsParsed.max_running_jobs_per_provider,
267
+ maxQueuedJobs: limitsParsed.max_queued_jobs,
268
+ queueTimeoutMs: limitsParsed.queue_timeout_ms,
269
+ completedJobMemoryTtlMs: limitsParsed.completed_job_memory_ttl_ms,
270
+ maxJobOutputBytes: limitsParsed.max_job_output_bytes,
271
+ },
272
+ sources: { configFile: sourcePath },
273
+ };
274
+ }
188
275
  export const ANTHROPIC_TTL_SECONDS_VALUES = [300, 3600];
189
276
  export const DEFAULT_MIN_STABLE_TOKENS_FOR_CACHE_CONTROL = {
190
277
  sonnet: 1024,
@@ -386,6 +473,9 @@ export function isApiProviderEnabled(provider, env = process.env) {
386
473
  return true;
387
474
  return provider.kind === "openai-compatible" && isLoopbackUrl(provider.baseUrl);
388
475
  }
476
+ export function apiProviderKeyPresent(provider, env = process.env) {
477
+ return resolveProviderKey(provider.apiKeyEnv, env) !== null;
478
+ }
389
479
  export function enabledApiProviders(config, env = process.env) {
390
480
  const runtimes = [];
391
481
  for (const provider of Object.values(config.providers ?? {})) {
@@ -394,6 +484,7 @@ export function enabledApiProviders(config, env = process.env) {
394
484
  runtimes.push({
395
485
  name: provider.name,
396
486
  kind: provider.kind,
487
+ apiKeyEnv: provider.apiKeyEnv,
397
488
  baseUrl: provider.baseUrl,
398
489
  defaultModel: provider.defaultModel,
399
490
  models: provider.models,
package/dist/doctor.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type EndpointExposureReport } from "./endpoint-exposure.js";
2
2
  import { type ProviderLoginStatus } from "./provider-status.js";
3
+ import { type ApiProviderLoginGuidance } from "./provider-login-guidance.js";
3
4
  import type { FlightRecorderQuery } from "./flight-recorder.js";
4
- import { type CacheAwarenessConfig } from "./config.js";
5
+ import { type CacheAwarenessConfig, type ProvidersConfig } from "./config.js";
5
6
  import { type ProviderCapabilityId, type ProviderKind } from "./provider-tool-capabilities.js";
6
7
  export type CliType = "claude" | "codex" | "gemini" | "grok" | "mistral" | "devin";
7
8
  export interface CacheAwarenessReport {
@@ -38,6 +39,22 @@ export interface ProviderCapabilitySummaryReport {
38
39
  warnings: string[];
39
40
  }>;
40
41
  }
42
+ export interface ApiProviderHealthEntry {
43
+ name: string;
44
+ kind: "openai-compatible" | "anthropic" | "xai-responses";
45
+ base_url: string;
46
+ default_model: string;
47
+ models: string[] | null;
48
+ api_key_env: string | null;
49
+ api_key_present: boolean;
50
+ reachable: boolean | null;
51
+ reachability_error?: string;
52
+ login_guidance: ApiProviderLoginGuidance;
53
+ }
54
+ export interface ApiProviderHealthReport {
55
+ enabled_count: number;
56
+ providers: Record<string, ApiProviderHealthEntry>;
57
+ }
41
58
  export interface VibeSessionLoggingStatus {
42
59
  config_path: string;
43
60
  config_present: boolean;
@@ -138,6 +155,7 @@ export interface DoctorReport {
138
155
  };
139
156
  cache_awareness: CacheAwarenessReport;
140
157
  provider_capabilities: ProviderCapabilitySummaryReport;
158
+ api_providers?: ApiProviderHealthReport;
141
159
  upstream: {
142
160
  note: string;
143
161
  recommendation: string;
@@ -154,8 +172,18 @@ export interface CreateDoctorReportOptions {
154
172
  flightRecorder?: FlightRecorderQuery;
155
173
  cacheAwareness?: CacheAwarenessConfig;
156
174
  probeUpstream?: boolean;
175
+ providersConfig?: ProvidersConfig;
176
+ apiReachability?: Record<string, {
177
+ reachable: boolean;
178
+ error?: string;
179
+ }>;
157
180
  }
158
181
  export declare function createDoctorReport(envOrOptions?: NodeJS.ProcessEnv | CreateDoctorReportOptions): DoctorReport;
159
182
  export declare function printDoctorJson(opts?: {
160
183
  probeUpstream?: boolean;
161
- }): void;
184
+ probeApiProviders?: boolean;
185
+ }): Promise<void>;
186
+ export declare function probeApiProviderReachability(baseUrl: string, timeoutMs?: number): Promise<{
187
+ reachable: boolean;
188
+ error?: string;
189
+ }>;
package/dist/doctor.js CHANGED
@@ -4,14 +4,15 @@ import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { loadAuthConfig } from "./auth.js";
6
6
  import { createEndpointExposureReport, redactDiagnosticUrl, } from "./endpoint-exposure.js";
7
- import { listProviderRuntimeStatuses, } from "./provider-status.js";
7
+ import { getApiProviderStatus, listProviderRuntimeStatuses, } from "./provider-status.js";
8
+ import { getApiProviderLoginGuidance, } from "./provider-login-guidance.js";
8
9
  import { CLAUDE_MCP_SERVER_NAMES } from "./claude-mcp-config.js";
9
- import { loadCacheAwarenessConfig, loadRemoteOAuthConfig, } from "./config.js";
10
+ import { enabledApiProviders, loadCacheAwarenessConfig, loadProvidersConfig, loadRemoteOAuthConfig, } from "./config.js";
10
11
  import { loadWorkspaceRegistry } from "./workspace-registry.js";
11
12
  import { computeGlobalCacheStats } from "./cache-stats.js";
12
13
  import { FlightRecorder, resolveFlightRecorderDbPath } from "./flight-recorder.js";
13
14
  import { buildUpstreamContractReport } from "./upstream-contracts.js";
14
- import { getProviderToolCapabilities, providerCapabilityIds, } from "./provider-tool-capabilities.js";
15
+ import { getProviderToolCapabilities, knownProviderCapabilityIds, } from "./provider-tool-capabilities.js";
15
16
  export function checkVibeSessionLogging(home = homedir()) {
16
17
  const configPath = join(home, ".vibe", "config.toml");
17
18
  if (!existsSync(configPath)) {
@@ -172,6 +173,34 @@ function chatGPTConnectorUrl(env, rawPublicUrl) {
172
173
  return null;
173
174
  return "<redacted>";
174
175
  }
176
+ function buildApiProviderHealthReport(opts, env) {
177
+ const config = opts.providersConfig;
178
+ if (!config)
179
+ return undefined;
180
+ const providers = {};
181
+ for (const providerConfig of Object.values(config.providers)) {
182
+ const status = getApiProviderStatus(providerConfig, env);
183
+ if (!status.enabled)
184
+ continue;
185
+ const probe = opts.apiReachability?.[status.provider];
186
+ providers[status.provider] = {
187
+ name: status.provider,
188
+ kind: status.kind,
189
+ base_url: status.baseUrl,
190
+ default_model: status.defaultModel,
191
+ models: status.models,
192
+ api_key_env: status.apiKeyEnv,
193
+ api_key_present: status.apiKeyPresent,
194
+ reachable: probe ? probe.reachable : null,
195
+ ...(probe?.error ? { reachability_error: probe.error } : {}),
196
+ login_guidance: getApiProviderLoginGuidance(providerConfig),
197
+ };
198
+ }
199
+ const names = Object.keys(providers);
200
+ if (names.length === 0)
201
+ return undefined;
202
+ return { enabled_count: names.length, providers };
203
+ }
175
204
  function buildCacheAwarenessReport(opts) {
176
205
  const enabled = [];
177
206
  if (opts.cacheAwareness?.emitAnthropicCacheControl) {
@@ -234,7 +263,7 @@ function buildProviderCapabilitySummary(providerStatuses) {
234
263
  includeUnsupported: true,
235
264
  includePaths: false,
236
265
  });
237
- const providers = Object.fromEntries(providerCapabilityIds().map(provider => {
266
+ const providers = Object.fromEntries(knownProviderCapabilityIds().map(provider => {
238
267
  const capability = capabilities[provider];
239
268
  if (!capability) {
240
269
  throw new Error(`Missing provider capability record for ${provider}`);
@@ -264,7 +293,7 @@ function buildProviderCapabilitySummary(providerStatuses) {
264
293
  tool: "provider_tool_capabilities",
265
294
  resources: {
266
295
  catalog: "provider-tools://catalog",
267
- providers: Object.fromEntries(providerCapabilityIds().map(provider => [provider, `provider-tools://${provider}`])),
296
+ providers: Object.fromEntries(knownProviderCapabilityIds().map(provider => [provider, `provider-tools://${provider}`])),
268
297
  },
269
298
  cache_ttl_ms: 60_000,
270
299
  providers,
@@ -364,6 +393,10 @@ export function createDoctorReport(envOrOptions = process.env) {
364
393
  upstream,
365
394
  next_actions: [],
366
395
  };
396
+ const apiProviders = buildApiProviderHealthReport(opts, env);
397
+ if (apiProviders) {
398
+ report.api_providers = apiProviders;
399
+ }
367
400
  if (transport === "http" && auth.required && !auth.tokenConfigured) {
368
401
  report.ok = false;
369
402
  report.next_actions.push("Set LLM_GATEWAY_AUTH_TOKEN before starting HTTP transport.");
@@ -402,9 +435,10 @@ export function createDoctorReport(envOrOptions = process.env) {
402
435
  }
403
436
  return report;
404
437
  }
405
- export function printDoctorJson(opts = {}) {
438
+ export async function printDoctorJson(opts = {}) {
406
439
  let cacheAwareness;
407
440
  let flightRecorder;
441
+ let providersConfig;
408
442
  try {
409
443
  cacheAwareness = loadCacheAwarenessConfig();
410
444
  }
@@ -417,11 +451,25 @@ export function printDoctorJson(opts = {}) {
417
451
  }
418
452
  catch {
419
453
  }
454
+ try {
455
+ providersConfig = loadProvidersConfig();
456
+ }
457
+ catch {
458
+ }
459
+ let apiReachability;
460
+ if (opts.probeApiProviders && providersConfig) {
461
+ apiReachability = {};
462
+ for (const runtime of enabledApiProviders(providersConfig)) {
463
+ apiReachability[runtime.name] = await probeApiProviderReachability(runtime.baseUrl);
464
+ }
465
+ }
420
466
  const report = createDoctorReport({
421
467
  env: process.env,
422
468
  cacheAwareness,
423
469
  flightRecorder,
424
470
  probeUpstream: opts.probeUpstream,
471
+ providersConfig,
472
+ apiReachability,
425
473
  });
426
474
  process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
427
475
  if (flightRecorder) {
@@ -432,6 +480,30 @@ export function printDoctorJson(opts = {}) {
432
480
  }
433
481
  }
434
482
  }
483
+ export async function probeApiProviderReachability(baseUrl, timeoutMs = 5_000) {
484
+ let url;
485
+ try {
486
+ url = new URL(baseUrl);
487
+ }
488
+ catch {
489
+ return { reachable: false, error: "invalid base_url" };
490
+ }
491
+ const transport = url.protocol === "http:" ? await import("node:http") : await import("node:https");
492
+ return new Promise(resolve => {
493
+ const request = transport.request(url, { method: "GET", timeout: timeoutMs }, response => {
494
+ response.resume();
495
+ resolve({ reachable: true });
496
+ });
497
+ request.on("timeout", () => {
498
+ request.destroy();
499
+ resolve({ reachable: false, error: `timed out after ${timeoutMs}ms` });
500
+ });
501
+ request.on("error", err => {
502
+ resolve({ reachable: false, error: err instanceof Error ? err.message : String(err) });
503
+ });
504
+ request.end();
505
+ });
506
+ }
435
507
  function isCreateDoctorReportOptions(value) {
436
508
  if (value === null || typeof value !== "object")
437
509
  return false;
@@ -68,16 +68,28 @@ export function redactDiagnosticUrl(rawUrl) {
68
68
  const redactSensitivePairs = (value) => value.replace(new RegExp(`((${sensitiveKeyPattern.source})=)[^&\\s#]+`, "gi"), "$1<redacted>");
69
69
  try {
70
70
  const url = new URL(rawUrl);
71
- if (url.username)
71
+ let redacted = false;
72
+ if (url.username) {
72
73
  url.username = "<redacted>";
73
- if (url.password)
74
+ redacted = true;
75
+ }
76
+ if (url.password) {
74
77
  url.password = "<redacted>";
78
+ redacted = true;
79
+ }
75
80
  for (const key of Array.from(url.searchParams.keys())) {
76
81
  if (sensitiveKeyPattern.test(key)) {
77
82
  url.searchParams.set(key, "<redacted>");
83
+ redacted = true;
78
84
  }
79
85
  }
80
- url.hash = redactSensitivePairs(url.hash);
86
+ const redactedHash = redactSensitivePairs(url.hash);
87
+ if (redactedHash !== url.hash) {
88
+ url.hash = redactedHash;
89
+ redacted = true;
90
+ }
91
+ if (!redacted)
92
+ return rawUrl;
81
93
  return url.toString().replace(/%3Credacted%3E/gi, "<redacted>");
82
94
  }
83
95
  catch {
@@ -1,12 +1,14 @@
1
1
  import { type Server } from "node:http";
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import type { GatewayServerDeps } from "./index.js";
4
+ import { type HttpSessionLimitsConfig } from "./config.js";
4
5
  export interface HttpTransportOptions {
5
6
  host?: string;
6
7
  port?: number;
7
8
  path?: string;
8
9
  deps?: GatewayServerDeps;
9
10
  createGatewayServer: (deps?: GatewayServerDeps) => McpServer;
11
+ httpLimits?: HttpSessionLimitsConfig;
10
12
  logger?: {
11
13
  info: (...args: any[]) => void;
12
14
  error: (...args: any[]) => void;
@@ -18,5 +20,6 @@ export interface HttpGatewayHandle {
18
20
  url: string;
19
21
  close: () => Promise<void>;
20
22
  sessionCount: () => number;
23
+ sessionHealth: () => Record<string, unknown>;
21
24
  }
22
25
  export declare function startHttpGateway(options: HttpTransportOptions): Promise<HttpGatewayHandle>;
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
4
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { authorizeBearerRequest, getRequiredBearerToken, resolveTrustedPrincipal, writeAuthFailure, } from "./auth.js";
6
- import { loadRemoteOAuthConfig } from "./config.js";
6
+ import { loadRemoteOAuthConfig, loadLimitsConfig } from "./config.js";
7
7
  import { OAuthServer, oauthBaseUrlFromRequest } from "./oauth.js";
8
8
  import { runWithRequestContext } from "./request-context.js";
9
9
  import { readCappedRawBody, maxHttpBodyBytes } from "./request-limits.js";
@@ -79,6 +79,8 @@ export async function startHttpGateway(options) {
79
79
  const noAuthPaths = parseNoAuthPaths(process.env.LLM_GATEWAY_NO_AUTH_PATHS, path);
80
80
  const logger = options.logger ?? noopLogger;
81
81
  const sessions = new Map();
82
+ let pendingInitializes = 0;
83
+ const httpLimits = options.httpLimits ?? loadLimitsConfig(logger).http;
82
84
  const token = getRequiredBearerToken();
83
85
  const oauthConfig = loadRemoteOAuthConfig(logger);
84
86
  if (oauthConfig.enabled &&
@@ -101,14 +103,33 @@ export async function startHttpGateway(options) {
101
103
  .catch(error => logger.error("HTTP transport close failed", error));
102
104
  await entry.server.close().catch(error => logger.error("HTTP MCP server close failed", error));
103
105
  }
104
- async function createSession() {
106
+ function touchSessionComplete(entry) {
107
+ entry.inFlight = Math.max(0, entry.inFlight - 1);
108
+ entry.lastActivityAt = Date.now();
109
+ }
110
+ async function createSession(releaseInitializeReservation) {
105
111
  const gatewayServer = options.createGatewayServer(options.deps);
112
+ let entry;
106
113
  const transport = new StreamableHTTPServerTransport({
107
114
  sessionIdGenerator: () => randomUUID(),
108
115
  onsessioninitialized: sessionId => {
109
- sessions.set(sessionId, { server: gatewayServer, transport });
116
+ const now = Date.now();
117
+ releaseInitializeReservation();
118
+ entry.sessionId = sessionId;
119
+ entry.createdAt = now;
120
+ entry.lastActivityAt = now;
121
+ entry.inFlight++;
122
+ sessions.set(sessionId, entry);
110
123
  },
111
124
  });
125
+ const now = Date.now();
126
+ entry = {
127
+ server: gatewayServer,
128
+ transport,
129
+ createdAt: now,
130
+ lastActivityAt: now,
131
+ inFlight: 0,
132
+ };
112
133
  transport.onclose = () => {
113
134
  if (transport.sessionId) {
114
135
  sessions.delete(transport.sessionId);
@@ -116,7 +137,70 @@ export async function startHttpGateway(options) {
116
137
  };
117
138
  transport.onerror = error => logger.error("HTTP MCP transport error", error);
118
139
  await gatewayServer.connect(transport);
119
- return { server: gatewayServer, transport };
140
+ return entry;
141
+ }
142
+ function reapIdleSessions() {
143
+ const now = Date.now();
144
+ for (const [sessionId, entry] of sessions) {
145
+ if (entry.inFlight > 0)
146
+ continue;
147
+ if (now - entry.lastActivityAt < httpLimits.sessionIdleTtlMs)
148
+ continue;
149
+ logger.info(`Reaping idle HTTP MCP session (idle ${now - entry.lastActivityAt}ms >= ${httpLimits.sessionIdleTtlMs}ms)`);
150
+ void closeSession(sessionId);
151
+ }
152
+ }
153
+ const reaperTimer = setInterval(reapIdleSessions, httpLimits.sessionReaperIntervalMs);
154
+ if (reaperTimer.unref)
155
+ reaperTimer.unref();
156
+ function sessionHealth() {
157
+ const now = Date.now();
158
+ let oldestAgeMs = 0;
159
+ for (const entry of sessions.values()) {
160
+ const age = now - entry.createdAt;
161
+ if (age > oldestAgeMs)
162
+ oldestAgeMs = age;
163
+ }
164
+ return {
165
+ current: sessions.size,
166
+ max: httpLimits.maxSessions,
167
+ oldestAgeMs,
168
+ idleTtlMs: httpLimits.sessionIdleTtlMs,
169
+ reaperIntervalMs: httpLimits.sessionReaperIntervalMs,
170
+ saturated: sessions.size + pendingInitializes >= httpLimits.maxSessions,
171
+ };
172
+ }
173
+ function healthPayload() {
174
+ const manager = options.deps?.asyncJobManager;
175
+ const mem = process.memoryUsage();
176
+ const payload = {
177
+ sessions: sessionHealth(),
178
+ memory: {
179
+ rss: mem.rss,
180
+ heapUsed: mem.heapUsed,
181
+ heapTotal: mem.heapTotal,
182
+ external: mem.external,
183
+ },
184
+ };
185
+ if (manager) {
186
+ const limiter = manager.getLimiterSnapshot();
187
+ const limits = manager.getConfiguredLimits();
188
+ payload.jobs = {
189
+ running: limiter.running,
190
+ queued: limiter.queued,
191
+ runningByProvider: limiter.runningByProvider,
192
+ queuedByProvider: limiter.queuedByProvider,
193
+ maxRunning: limiter.maxRunning,
194
+ maxRunningPerProvider: limiter.maxRunningPerProvider,
195
+ maxQueued: limiter.maxQueued,
196
+ rejected: limiter.rejected,
197
+ timedOut: limiter.timedOut,
198
+ saturated: limiter.saturated,
199
+ completedJobMemoryTtlMs: limits.completedJobMemoryTtlMs,
200
+ maxJobOutputBytes: limits.maxJobOutputBytes,
201
+ };
202
+ }
203
+ return payload;
120
204
  }
121
205
  const httpServer = createServer(async (req, res) => {
122
206
  try {
@@ -127,7 +211,7 @@ export async function startHttpGateway(options) {
127
211
  const resourceMetadataUrl = oauthServer && oauthOrigin ? oauthServer.resourceMetadataUrl(oauthOrigin) : undefined;
128
212
  if (url.pathname === "/healthz") {
129
213
  res.writeHead(200, { "content-type": "application/json" });
130
- res.end(JSON.stringify({ ok: true, sessions: sessions.size }));
214
+ res.end(JSON.stringify({ ok: true, ...healthPayload() }));
131
215
  return;
132
216
  }
133
217
  if (oauthServer) {
@@ -188,7 +272,14 @@ export async function startHttpGateway(options) {
188
272
  return;
189
273
  }
190
274
  const body = req.method === "POST" ? await readBody(req) : undefined;
191
- await runWithRequestContext(requestContext, () => entry.transport.handleRequest(req, res, body));
275
+ entry.lastActivityAt = Date.now();
276
+ entry.inFlight++;
277
+ try {
278
+ await runWithRequestContext(requestContext, () => entry.transport.handleRequest(req, res, body));
279
+ }
280
+ finally {
281
+ touchSessionComplete(entry);
282
+ }
192
283
  return;
193
284
  }
194
285
  if (req.method !== "POST") {
@@ -204,8 +295,45 @@ export async function startHttpGateway(options) {
204
295
  jsonError(res, 400, "First request must be initialize");
205
296
  return;
206
297
  }
207
- const entry = await createSession();
208
- await runWithRequestContext(requestContext, () => entry.transport.handleRequest(req, res, body));
298
+ const capacityInUse = sessions.size + pendingInitializes;
299
+ if (capacityInUse >= httpLimits.maxSessions) {
300
+ res.writeHead(429, {
301
+ "content-type": "application/json",
302
+ "retry-after": "5",
303
+ });
304
+ res.end(JSON.stringify({
305
+ error: "Gateway at session capacity",
306
+ code: "session_capacity",
307
+ retryable: true,
308
+ sessions: {
309
+ current: sessions.size,
310
+ pending: pendingInitializes,
311
+ max: httpLimits.maxSessions,
312
+ },
313
+ }));
314
+ logger.info(`Rejected new HTTP MCP session: at capacity (${capacityInUse}/${httpLimits.maxSessions})`);
315
+ return;
316
+ }
317
+ pendingInitializes++;
318
+ let reservationReleased = false;
319
+ const releaseInitializeReservation = () => {
320
+ if (reservationReleased)
321
+ return;
322
+ reservationReleased = true;
323
+ pendingInitializes = Math.max(0, pendingInitializes - 1);
324
+ };
325
+ let entry;
326
+ try {
327
+ const created = await createSession(releaseInitializeReservation);
328
+ entry = created;
329
+ await runWithRequestContext(requestContext, () => created.transport.handleRequest(req, res, body));
330
+ }
331
+ finally {
332
+ releaseInitializeReservation();
333
+ if (entry?.sessionId && sessions.get(entry.sessionId) === entry && entry.inFlight > 0) {
334
+ touchSessionComplete(entry);
335
+ }
336
+ }
209
337
  }
210
338
  catch (error) {
211
339
  logger.error("HTTP transport request failed", error);
@@ -241,11 +369,13 @@ export async function startHttpGateway(options) {
241
369
  server: httpServer,
242
370
  url,
243
371
  close: async () => {
372
+ clearInterval(reaperTimer);
244
373
  await Promise.all([...sessions.keys()].map(closeSession));
245
374
  await new Promise((resolve, reject) => {
246
375
  httpServer.close(error => (error ? reject(error) : resolve()));
247
376
  });
248
377
  },
249
378
  sessionCount: () => sessions.size,
379
+ sessionHealth,
250
380
  };
251
381
  }
package/dist/index.d.ts CHANGED
@@ -124,6 +124,20 @@ export declare function createErrorResponse(cli: string, code: number, stderr: s
124
124
  httpStatus?: number | null;
125
125
  responseBody?: string;
126
126
  }): {
127
+ content: {
128
+ type: "text";
129
+ text: string;
130
+ }[];
131
+ isError: boolean;
132
+ structuredContent: {
133
+ response: string;
134
+ correlationId: string | null;
135
+ cli: string;
136
+ exitCode: number;
137
+ errorCategory: string;
138
+ retryable: boolean;
139
+ };
140
+ } | {
127
141
  content: {
128
142
  type: "text";
129
143
  text: string;
@@ -137,6 +151,7 @@ export declare function createErrorResponse(cli: string, code: number, stderr: s
137
151
  cli: string;
138
152
  exitCode: number;
139
153
  errorCategory: string;
154
+ retryable?: undefined;
140
155
  };
141
156
  };
142
157
  export declare function extractUsageAndCost(cli: "claude" | "codex" | "gemini" | "grok" | "mistral" | "devin", output: string, outputFormat?: string, ctx?: {