@stigmer/mcp-server 3.12.1 → 3.12.3

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/src/server.ts CHANGED
@@ -19,6 +19,7 @@ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
19
19
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
20
20
 
21
21
  import type { Config } from "./config.js";
22
+ import { createReadinessCheck, type ReadinessResult } from "./readiness.js";
22
23
  import { registerAgentExecutionTools } from "./domains/agentexecutions/tools.js";
23
24
  import { registerAgentResources } from "./domains/agents/resources.js";
24
25
  import { registerAgentTools } from "./domains/agents/tools.js";
@@ -244,10 +245,11 @@ export async function serveHttp(
244
245
  signal: AbortSignal,
245
246
  ): Promise<void> {
246
247
  const sessions = new Map<string, StreamableHTTPServerTransport>();
248
+ const checkReady = createReadinessCheck(cfg.stigmerServerAddress);
247
249
 
248
250
  const httpServer = createHttpServer((req, res) => {
249
251
  logAccess(req, res);
250
- void routeRequest(req, res, sessions, makeServer, cfg);
252
+ void routeRequest(req, res, sessions, makeServer, cfg, checkReady);
251
253
  });
252
254
 
253
255
  const addr = `:${cfg.httpPort}`;
@@ -301,13 +303,17 @@ export async function serveBoth(target: BackendTarget, cfg: Config, signal: Abor
301
303
  }
302
304
 
303
305
  /**
304
- * Route an inbound HTTP request: liveness probe, the non-validating Bearer
305
- * extraction, then delegation to the session's MCP transport (reusing an
306
+ * Route an inbound HTTP request: liveness/readiness probes, the non-validating
307
+ * Bearer extraction, then delegation to the session's MCP transport (reusing an
306
308
  * existing session or creating one for an `initialize` request).
307
309
  *
308
310
  * The token is never validated here — presence is the only check, and it is
309
311
  * forwarded unchanged to stigmer-server which performs validation. This mirrors
310
312
  * the Go authMiddleware exactly (inventory §4.2).
313
+ *
314
+ * Every refusal is a JSON-RPC-framed error (the SDK transport's own
315
+ * convention — see jsonRpcError), never text/plain: strict MCP clients parse
316
+ * the body, and the spec keys session recovery on a recognizable 404.
311
317
  */
312
318
  async function routeRequest(
313
319
  req: IncomingMessage & { auth?: AuthInfo },
@@ -315,6 +321,7 @@ async function routeRequest(
315
321
  sessions: Map<string, StreamableHTTPServerTransport>,
316
322
  makeServer: RouteServerFactory,
317
323
  cfg: Config,
324
+ checkReady: () => Promise<ReadinessResult>,
318
325
  ): Promise<void> {
319
326
  if (req.method === "GET" && req.url === "/health") {
320
327
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -322,6 +329,21 @@ async function routeRequest(
322
329
  return;
323
330
  }
324
331
 
332
+ // Readiness = liveness AND a working backend hop (see readiness.ts for why
333
+ // only the readiness probe may point here). Public like /health: Kubernetes
334
+ // probes carry no bearer.
335
+ if (req.method === "GET" && requestPath(req) === "/ready") {
336
+ const result = await checkReady();
337
+ if (result.ready) {
338
+ res.writeHead(200, { "Content-Type": "application/json" });
339
+ res.end(`{"status":"ready"}\n`);
340
+ } else {
341
+ res.writeHead(503, { "Content-Type": "application/json" });
342
+ res.end(JSON.stringify({ status: "unready", reason: result.reason }) + "\n");
343
+ }
344
+ return;
345
+ }
346
+
325
347
  // RFC 9728 Protected Resource Metadata — public, unauthenticated, and served
326
348
  // only when OAuth discovery is enabled. CORS-open so browser-based clients
327
349
  // (e.g. Claude Desktop's connector GUI) can discover the authorization server.
@@ -335,11 +357,11 @@ async function routeRequest(
335
357
  if (cfg.httpAuthEnabled) {
336
358
  const token = extractBearerToken(req);
337
359
  if (token === "") {
338
- const headers: Record<string, string> = { "Content-Type": "text/plain" };
339
360
  // RFC 9728 §5.1: point OAuth-capable clients at the metadata document.
340
- if (cfg.oauth.enabled) headers["WWW-Authenticate"] = bearerChallenge(cfg);
341
- res.writeHead(401, headers);
342
- res.end("missing or malformed Authorization: Bearer header");
361
+ const challenge = cfg.oauth.enabled
362
+ ? { "WWW-Authenticate": bearerChallenge(cfg) }
363
+ : undefined;
364
+ jsonRpcError(res, 401, -32000, "missing or malformed Authorization: Bearer header", challenge);
343
365
  return;
344
366
  }
345
367
  req.auth = { token, clientId: "stigmer-mcp-passthrough", scopes: [] };
@@ -352,8 +374,12 @@ async function routeRequest(
352
374
  if (sessionId !== undefined) {
353
375
  const transport = sessions.get(sessionId);
354
376
  if (transport === undefined) {
355
- res.writeHead(404, { "Content-Type": "text/plain" });
356
- res.end("unknown or expired MCP session");
377
+ // 404 + code -32001 is the SDK transport's session-not-found shape, and
378
+ // the streamable-HTTP spec's recovery signal: on it, a client MUST open
379
+ // a new session with a fresh InitializeRequest. Losing sessions on pod
380
+ // restart is expected here — they are in-memory by design (single
381
+ // replica; see the deployment overlay).
382
+ jsonRpcError(res, 404, -32001, "Session not found: unknown or expired MCP session");
357
383
  return;
358
384
  }
359
385
  await transport.handleRequest(req, res);
@@ -362,15 +388,13 @@ async function routeRequest(
362
388
 
363
389
  // No session → only an initialize POST may open one.
364
390
  if (req.method !== "POST") {
365
- res.writeHead(400, { "Content-Type": "text/plain" });
366
- res.end("missing Mcp-Session-Id header");
391
+ jsonRpcError(res, 400, -32000, "Bad Request: Mcp-Session-Id header is required");
367
392
  return;
368
393
  }
369
394
 
370
395
  const body = await readJsonBody(req);
371
396
  if (!isInitializeRequest(body)) {
372
- res.writeHead(400, { "Content-Type": "text/plain" });
373
- res.end("Bad Request: an initialize request is required to open a session");
397
+ jsonRpcError(res, 400, -32000, "Bad Request: an initialize request is required to open a session");
374
398
  return;
375
399
  }
376
400
 
@@ -379,8 +403,7 @@ async function routeRequest(
379
403
  // routedServerFactory for the incident this prevents.
380
404
  const server = makeServer(path);
381
405
  if (server === undefined) {
382
- res.writeHead(404, { "Content-Type": "text/plain" });
383
- res.end(`unknown MCP route: ${path}`);
406
+ jsonRpcError(res, 404, -32000, `unknown MCP route: ${path}`);
384
407
  return;
385
408
  }
386
409
 
@@ -411,6 +434,26 @@ export function stdioServer(target: BackendTarget, cfg: Config): McpServer {
411
434
  return createServer(target);
412
435
  }
413
436
 
437
+ /**
438
+ * Write a JSON-RPC-framed refusal, byte-compatible with the SDK transport's
439
+ * own createJsonErrorResponse: `{"jsonrpc":"2.0","error":{code,message},"id":null}`
440
+ * with Content-Type application/json. The HTTP status stays authoritative (the
441
+ * streamable-HTTP spec keys on it); the body exists for strict MCP clients
442
+ * that parse refusals instead of surfacing an opaque content-type error
443
+ * (stigmer/stigmer#316). Code -32001 is reserved for session-not-found; the
444
+ * transport-level family uses -32000, both per the SDK's convention.
445
+ */
446
+ function jsonRpcError(
447
+ res: ServerResponse,
448
+ status: number,
449
+ code: number,
450
+ message: string,
451
+ headers?: Record<string, string>,
452
+ ): void {
453
+ res.writeHead(status, { "Content-Type": "application/json", ...headers });
454
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }));
455
+ }
456
+
414
457
  /** Return a single header value, collapsing the array form Node may produce. */
415
458
  function headerValue(req: IncomingMessage, name: string): string | undefined {
416
459
  const v = req.headers[name];