llm-cli-gateway 2.12.1 → 2.13.0

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +177 -19
  3. package/dist/acp/provider-registry.js +13 -0
  4. package/dist/api-provider.d.ts +1 -0
  5. package/dist/api-provider.js +13 -0
  6. package/dist/approval-manager.d.ts +1 -1
  7. package/dist/async-job-manager.d.ts +35 -3
  8. package/dist/async-job-manager.js +344 -46
  9. package/dist/cli-updater.d.ts +1 -1
  10. package/dist/cli-updater.js +17 -9
  11. package/dist/config.d.ts +34 -0
  12. package/dist/config.js +93 -8
  13. package/dist/doctor.d.ts +32 -4
  14. package/dist/doctor.js +80 -13
  15. package/dist/endpoint-exposure.js +15 -3
  16. package/dist/executor.js +2 -0
  17. package/dist/http-transport.d.ts +3 -0
  18. package/dist/http-transport.js +138 -8
  19. package/dist/index.d.ts +62 -5
  20. package/dist/index.js +774 -83
  21. package/dist/model-registry.d.ts +1 -1
  22. package/dist/model-registry.js +12 -16
  23. package/dist/provider-login-guidance.d.ts +12 -0
  24. package/dist/provider-login-guidance.js +46 -0
  25. package/dist/provider-status.d.ts +13 -1
  26. package/dist/provider-status.js +22 -13
  27. package/dist/provider-tool-capabilities.d.ts +4 -1
  28. package/dist/provider-tool-capabilities.js +310 -11
  29. package/dist/provider-types.d.ts +4 -0
  30. package/dist/provider-types.js +10 -0
  31. package/dist/resources.d.ts +6 -2
  32. package/dist/resources.js +72 -8
  33. package/dist/session-manager.d.ts +3 -5
  34. package/dist/session-manager.js +4 -2
  35. package/dist/upstream-contracts.js +169 -0
  36. package/dist/validation-normalizer.js +5 -4
  37. package/dist/validation-orchestrator.js +4 -0
  38. package/npm-shrinkwrap.json +2 -2
  39. package/package.json +1 -1
  40. package/setup/status.schema.json +68 -3
@@ -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
@@ -7,7 +7,7 @@ import { PerformanceMetrics } from "./metrics.js";
7
7
  import { type PersistenceConfig, type CacheAwarenessConfig, type ProvidersConfig, type ApiProviderRuntime, type AcpConfig } from "./config.js";
8
8
  import { DatabaseConnection } from "./db.js";
9
9
  import { AsyncJobManager } from "./async-job-manager.js";
10
- import { ApprovalManager, ApprovalRecord } from "./approval-manager.js";
10
+ import { ApprovalManager, ApprovalPolicy, ApprovalRecord } from "./approval-manager.js";
11
11
  import { ReviewIntegrityResult } from "./review-integrity.js";
12
12
  import { ClaudeMcpConfigResult, ClaudeMcpServerName } from "./claude-mcp-config.js";
13
13
  import { type MistralAgentMode, type ClaudePermissionMode, type CodexSandboxMode, type CodexAskForApproval, type ClaudeEffortLevel } from "./request-helpers.js";
@@ -60,8 +60,8 @@ export declare const WORKTREE_SCHEMA: z.ZodUnion<[z.ZodBoolean, z.ZodObject<{
60
60
  ref?: string | undefined;
61
61
  }>]>;
62
62
  export declare const WORKSPACE_ALIAS_SCHEMA: z.ZodString;
63
- export declare const SESSION_PROVIDER_VALUES: readonly ["claude", "codex", "gemini", "grok", "mistral", "devin", "grok-api"];
64
- export declare const SESSION_PROVIDER_ENUM: z.ZodEnum<["claude", "codex", "gemini", "grok", "mistral", "devin", "grok-api"]>;
63
+ export declare const SESSION_PROVIDER_VALUES: readonly ["claude", "codex", "gemini", "grok", "mistral", "devin", "cursor", "grok-api"];
64
+ export declare const SESSION_PROVIDER_ENUM: z.ZodEnum<["claude", "codex", "gemini", "grok", "mistral", "devin", "cursor", "grok-api"]>;
65
65
  export declare function sessionProviderValuesFor(providers: ProvidersConfig): string[];
66
66
  export type SessionProvider = ProviderType;
67
67
  export interface GatewayServerDeps {
@@ -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,9 +151,10 @@ 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
- export declare function extractUsageAndCost(cli: "claude" | "codex" | "gemini" | "grok" | "mistral" | "devin", output: string, outputFormat?: string, ctx?: {
157
+ export declare function extractUsageAndCost(cli: CliType, output: string, outputFormat?: string, ctx?: {
143
158
  sessionId?: string;
144
159
  home?: string;
145
160
  }): {
@@ -333,7 +348,7 @@ export declare function buildMistralRetryPrep(params: Pick<MistralRequestParams,
333
348
  env: Record<string, string>;
334
349
  ignoredDisallowedTools: boolean;
335
350
  };
336
- export declare function buildCliResponse(cli: "claude" | "codex" | "gemini" | "grok" | "mistral" | "devin", stdout: string, optimizeResponse: boolean, corrId: string, sessionId: string | undefined, prep: CliRequestPrep, durationMs: number, resumable?: boolean, outputFormat?: string, warnings?: WarningEntry[]): ExtendedToolResponse;
351
+ export declare function buildCliResponse(cli: CliType, stdout: string, optimizeResponse: boolean, corrId: string, sessionId: string | undefined, prep: CliRequestPrep, durationMs: number, resumable?: boolean, outputFormat?: string, warnings?: WarningEntry[]): ExtendedToolResponse;
337
352
  export interface GrokApiRequestParams {
338
353
  prompt?: string;
339
354
  promptParts?: PromptParts;
@@ -507,6 +522,48 @@ export declare function prepareDevinRequest(params: {
507
522
  }, _runtime: GatewayServerRuntime): CliRequestPrep | ExtendedToolResponse;
508
523
  export declare function handleDevinRequest(deps: HandlerDeps, params: DevinRequestParams): Promise<ExtendedToolResponse>;
509
524
  export declare function handleDevinRequestAsync(deps: AsyncHandlerDeps, params: Omit<DevinRequestParams, "optimizeResponse">): Promise<ExtendedToolResponse>;
525
+ export interface CursorRequestParams {
526
+ prompt?: string;
527
+ model?: string;
528
+ mode?: "plan" | "ask";
529
+ outputFormat?: "text" | "json" | "stream-json";
530
+ transport?: "cli" | "acp";
531
+ force?: boolean;
532
+ autoReview?: boolean;
533
+ sandbox?: "enabled" | "disabled";
534
+ trust?: boolean;
535
+ workspace?: string;
536
+ addDir?: string[];
537
+ sessionId?: string;
538
+ resumeLatest?: boolean;
539
+ createNewSession?: boolean;
540
+ approvalStrategy?: "legacy" | "mcp_managed";
541
+ approvalPolicy?: ApprovalPolicy;
542
+ correlationId?: string;
543
+ optimizePrompt: boolean;
544
+ optimizeResponse?: boolean;
545
+ idleTimeoutMs?: number;
546
+ forceRefresh?: boolean;
547
+ }
548
+ export declare function prepareCursorRequest(params: {
549
+ prompt?: string;
550
+ model?: string;
551
+ mode?: CursorRequestParams["mode"];
552
+ outputFormat?: CursorRequestParams["outputFormat"];
553
+ force?: boolean;
554
+ autoReview?: boolean;
555
+ sandbox?: CursorRequestParams["sandbox"];
556
+ trust?: boolean;
557
+ workspace?: string;
558
+ addDir?: string[];
559
+ approvalStrategy?: CursorRequestParams["approvalStrategy"];
560
+ approvalPolicy?: CursorRequestParams["approvalPolicy"];
561
+ correlationId?: string;
562
+ optimizePrompt: boolean;
563
+ operation: string;
564
+ }, runtime: GatewayServerRuntime): CliRequestPrep | ExtendedToolResponse;
565
+ export declare function handleCursorRequest(deps: HandlerDeps, params: CursorRequestParams): Promise<ExtendedToolResponse>;
566
+ export declare function handleCursorRequestAsync(deps: AsyncHandlerDeps, params: Omit<CursorRequestParams, "optimizeResponse">): Promise<ExtendedToolResponse>;
510
567
  export interface MistralRequestParams {
511
568
  prompt?: string;
512
569
  promptParts?: PromptParts;