@nanobpm/urban 0.90.0 → 0.90.1

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.
@@ -1073,6 +1073,17 @@ export function mountMcp(ctx, app, apiRoutes) {
1073
1073
  if (!session) {
1074
1074
  const messages = Array.isArray(parsedBody) ? parsedBody : [parsedBody];
1075
1075
  if (!messages.some((m) => isInitializeRequest(m))) {
1076
+ // A session id was supplied but is not (or no longer) resident — e.g. the client is
1077
+ // holding a session minted by a previous process that a restart wiped from the in-memory
1078
+ // map. Per the MCP Streamable-HTTP spec a terminated/unknown session id MUST answer 404 so
1079
+ // the client transparently re-initializes; only a genuinely missing id is a 400 bad client.
1080
+ if (sessionId) {
1081
+ return json({
1082
+ jsonrpc: "2.0",
1083
+ error: { code: -32001, message: `Session not found: unknown or expired ${SESSION_HEADER}.` },
1084
+ id: null,
1085
+ }, 404);
1086
+ }
1076
1087
  return json({
1077
1088
  jsonrpc: "2.0",
1078
1089
  error: { code: -32000, message: "Bad Request: no valid session id, and not an initialize request." },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.90.0",
3
+ "version": "0.90.1",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -251,6 +251,40 @@ test("a POST without a session and without an initialize request is a 400", asyn
251
251
  assert.equal(res.status, 400);
252
252
  });
253
253
 
254
+ test("a tools/call carrying an unknown/stale mcp-session-id is a 404, not a 400 (restart recovery)", async () => {
255
+ // A restart wipes the in-memory sessions map; a client still holding a session id from the
256
+ // previous process hits this branch. Per the MCP Streamable-HTTP spec the correct signal is 404
257
+ // (session terminated/expired) so the client transparently re-initializes — a 400 leaves the
258
+ // client hanging until its own request timeout instead of auto-reconnecting.
259
+ const { router } = buildHarness();
260
+ const res = await router(
261
+ mcpPost(
262
+ { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "listInvoices", arguments: {} } },
263
+ { "mcp-session-id": "stale-session-from-a-previous-process" },
264
+ ),
265
+ );
266
+ assert.equal(res.status, 404, `an unknown session id must be 404, got ${res.status}: ${res.body}`);
267
+ });
268
+
269
+ test("a present-but-unknown mcp-session-id WITH an initialize body still mints a fresh session", async () => {
270
+ // The client is already reconnecting — carrying a stale id alongside an initialize must not be
271
+ // rejected; it establishes a brand-new session exactly as a bare initialize would.
272
+ const { router } = buildHarness();
273
+ const res = await router(
274
+ mcpPost(
275
+ {
276
+ jsonrpc: "2.0",
277
+ id: 0,
278
+ method: "initialize",
279
+ params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "1.0" } },
280
+ },
281
+ { "mcp-session-id": "stale-session-from-a-previous-process" },
282
+ ),
283
+ );
284
+ assert.equal(res.status ?? 200, 200, `initialize with a stale id must be 200, got ${res.status}: ${res.body}`);
285
+ assert.ok(res.headers?.["mcp-session-id"], "a fresh session id must be assigned");
286
+ });
287
+
254
288
  // ---- tool projection --------------------------------------------------------------------------
255
289
 
256
290
  test("every app operation projects to a tool; read tools are annotated read-only, mutating ones destructive", async () => {
@@ -1230,6 +1230,20 @@ export function mountMcp(ctx: RuntimeContext, app: AppApi, apiRoutes: Route[]):
1230
1230
  if (!session) {
1231
1231
  const messages = Array.isArray(parsedBody) ? parsedBody : [parsedBody];
1232
1232
  if (!messages.some((m) => isInitializeRequest(m))) {
1233
+ // A session id was supplied but is not (or no longer) resident — e.g. the client is
1234
+ // holding a session minted by a previous process that a restart wiped from the in-memory
1235
+ // map. Per the MCP Streamable-HTTP spec a terminated/unknown session id MUST answer 404 so
1236
+ // the client transparently re-initializes; only a genuinely missing id is a 400 bad client.
1237
+ if (sessionId) {
1238
+ return json(
1239
+ {
1240
+ jsonrpc: "2.0",
1241
+ error: { code: -32001, message: `Session not found: unknown or expired ${SESSION_HEADER}.` },
1242
+ id: null,
1243
+ },
1244
+ 404,
1245
+ );
1246
+ }
1233
1247
  return json(
1234
1248
  {
1235
1249
  jsonrpc: "2.0",