@nanobpm/urban 0.90.0 → 0.90.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/cli.js +30 -3
- package/dist/runtime/adapters/globals.d.ts +11 -0
- package/dist/runtime/core/modules/mcp.js +11 -0
- package/package.json +1 -1
- package/src/cli.test.ts +89 -0
- package/src/cli.ts +33 -3
- package/src/runtime/adapters/globals.ts +12 -0
- package/src/runtime/core/modules/mcp.test.ts +34 -0
- package/src/runtime/core/modules/mcp.ts +14 -0
package/dist/cli.js
CHANGED
|
@@ -433,6 +433,30 @@ export async function main(argv) {
|
|
|
433
433
|
return 1;
|
|
434
434
|
}
|
|
435
435
|
}
|
|
436
|
+
/**
|
|
437
|
+
* Flush a Node writable stdio stream, resolving once its buffered writes have drained to the OS.
|
|
438
|
+
* `process.stdout`/`stderr` connected to a *pipe* is asynchronous in Node: `console.log` buffers
|
|
439
|
+
* the write and an immediate `process.exit()` abandons anything not yet handed to the OS, so a
|
|
440
|
+
* large reply (> the 64 KB pipe buffer on Linux) is silently truncated. Writing an empty chunk and
|
|
441
|
+
* waiting for its completion callback drains the prior writes first. Deno's stdio has no such
|
|
442
|
+
* `write(chunk, cb)` shape (its `console.log` is synchronous), so its absence is a no-op flush.
|
|
443
|
+
*/
|
|
444
|
+
function flushStream(stream) {
|
|
445
|
+
return new Promise((resolve) => {
|
|
446
|
+
if (!stream || typeof stream.write !== "function") {
|
|
447
|
+
resolve();
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
stream.write("", () => resolve());
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
/** Drain both stdout and stderr before an explicit `process.exit` so no buffered output is lost. */
|
|
454
|
+
async function flushStdio() {
|
|
455
|
+
const p = processGlobal();
|
|
456
|
+
if (!p)
|
|
457
|
+
return;
|
|
458
|
+
await Promise.all([flushStream(p.stdout), flushStream(p.stderr)]);
|
|
459
|
+
}
|
|
436
460
|
const proc = processGlobal();
|
|
437
461
|
const deno = denoGlobal();
|
|
438
462
|
const importMetaMain = Reflect.get(import.meta, "main");
|
|
@@ -450,11 +474,14 @@ if (importMetaMain === true || nodeMain) {
|
|
|
450
474
|
else if (deno?.exit)
|
|
451
475
|
deno.exit(code);
|
|
452
476
|
};
|
|
453
|
-
main(argv).then((code) => {
|
|
454
|
-
if (code >= 0)
|
|
477
|
+
main(argv).then(async (code) => {
|
|
478
|
+
if (code >= 0) {
|
|
479
|
+
await flushStdio();
|
|
455
480
|
exit(code);
|
|
456
|
-
|
|
481
|
+
}
|
|
482
|
+
}, async (err) => {
|
|
457
483
|
console.error(errorMessage(err));
|
|
484
|
+
await flushStdio();
|
|
458
485
|
exit(1);
|
|
459
486
|
});
|
|
460
487
|
}
|
|
@@ -7,12 +7,23 @@ export interface DenoRuntimeGlobal {
|
|
|
7
7
|
exit?: (code: number) => void;
|
|
8
8
|
addSignalListener?: (sig: string, cb: () => void) => void;
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* The subset of a Node writable stdio stream (`process.stdout` / `process.stderr`) we depend on:
|
|
12
|
+
* a `write` that accepts a completion callback fired once the chunk has flushed to the OS. Deno's
|
|
13
|
+
* stdio streams do not expose this shape; callers treat its absence as "already synchronous, no
|
|
14
|
+
* flush needed" (see `flushStdio` in cli.ts).
|
|
15
|
+
*/
|
|
16
|
+
export interface WritableStdio {
|
|
17
|
+
write?: (chunk: string, cb: () => void) => void;
|
|
18
|
+
}
|
|
10
19
|
export interface ProcessRuntimeGlobal {
|
|
11
20
|
argv?: string[];
|
|
12
21
|
exit?: (code: number) => void;
|
|
13
22
|
stdin?: AsyncIterable<Uint8Array | string> & {
|
|
14
23
|
setEncoding?(encoding: string): void;
|
|
15
24
|
};
|
|
25
|
+
stdout?: WritableStdio;
|
|
26
|
+
stderr?: WritableStdio;
|
|
16
27
|
on?: (sig: string, cb: () => void) => void;
|
|
17
28
|
}
|
|
18
29
|
export declare function denoGlobal(): DenoRuntimeGlobal | undefined;
|
|
@@ -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.
|
|
3
|
+
"version": "0.90.2",
|
|
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",
|
package/src/cli.test.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
3
8
|
import { cmdData } from "./cli.ts";
|
|
4
9
|
import { isRecord } from "./runtime/core/guards.ts";
|
|
10
|
+
import { createNodeHost } from "./runtime/adapters/node.ts";
|
|
11
|
+
import { runDataOp } from "./runtime/core/modules/dataops.ts";
|
|
5
12
|
|
|
6
13
|
// The `urban data` gateway's contract (see cmdData's docstring): *every* reply — including an
|
|
7
14
|
// unreadable request — is a parseable `{ ok: false, error }` envelope on stdout AND the process
|
|
@@ -41,3 +48,85 @@ test("urban data exits 0 with a parseable envelope on an unreadable request", as
|
|
|
41
48
|
if (typeof reply.error !== "string") throw new TypeError("expected string error");
|
|
42
49
|
assert.match(reply.error, /^bad request: /);
|
|
43
50
|
});
|
|
51
|
+
|
|
52
|
+
// Regression guard for nanobpm/nano-ide#549: `urban data`'s success path prints the full reply
|
|
53
|
+
// with `console.log`, then the top-level runner calls `process.exit(code)`. In Node, stdout
|
|
54
|
+
// connected to a *pipe* is asynchronous — an immediate `process.exit` abandons anything still
|
|
55
|
+
// buffered, truncating the reply at the OS pipe-buffer boundary (65536 bytes on Linux) so the
|
|
56
|
+
// console host fails to `JSON.parse` it ("EOF … column 65536"). The fix drains stdout before the
|
|
57
|
+
// explicit exit. This test spawns the real CLI on a datasource whose query result serialises to
|
|
58
|
+
// well over 64 KB, reads the piped stdout, and asserts the parent parses a complete envelope whose
|
|
59
|
+
// byte length matches the untruncated JSON. It FAILS before the fix (truncated at 65536) and
|
|
60
|
+
// passes after.
|
|
61
|
+
test("urban data streams a > 64 KB reply without truncation through an explicit process.exit", async () => {
|
|
62
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-data-large-"));
|
|
63
|
+
try {
|
|
64
|
+
await mkdir(join(dir, "db", "migrations"), { recursive: true });
|
|
65
|
+
await writeFile(
|
|
66
|
+
join(dir, "nano.app.json"),
|
|
67
|
+
JSON.stringify({
|
|
68
|
+
schemaVersion: 1,
|
|
69
|
+
id: "big",
|
|
70
|
+
name: "Big",
|
|
71
|
+
data: {
|
|
72
|
+
default: "app",
|
|
73
|
+
sources: { app: { driver: "sqlite", url: "file:./db/app.db", migrations: "db/migrations" } },
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
await writeFile(
|
|
78
|
+
join(dir, "db", "migrations", "001_big.sql"),
|
|
79
|
+
"CREATE TABLE big (id INTEGER PRIMARY KEY, payload TEXT NOT NULL);",
|
|
80
|
+
);
|
|
81
|
+
// 2000 rows × ~128-char payload → a query reply of roughly 300 KB, comfortably past the 64 KB
|
|
82
|
+
// pipe boundary. A single recursive-CTE INSERT (no inner `;`) so the migration splitter keeps
|
|
83
|
+
// it as one statement.
|
|
84
|
+
await writeFile(
|
|
85
|
+
join(dir, "db", "migrations", "002_seed.sql"),
|
|
86
|
+
"INSERT INTO big (id, payload) WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 2000) SELECT n, printf('%0128d', n) FROM seq;",
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const host = createNodeHost({ cwd: dir });
|
|
90
|
+
await runDataOp(host, ".", "nano.app.json", { op: "migrate" });
|
|
91
|
+
const query = { op: "query", sql: "SELECT * FROM big ORDER BY id" } as const;
|
|
92
|
+
const result = await runDataOp(host, ".", "nano.app.json", query);
|
|
93
|
+
const expected = `${JSON.stringify({ ok: true, ...result })}\n`;
|
|
94
|
+
const expectedBytes = Buffer.byteLength(expected);
|
|
95
|
+
assert.ok(
|
|
96
|
+
expectedBytes > 65536,
|
|
97
|
+
`fixture too small (${expectedBytes} bytes) to exercise the pipe-buffer boundary`,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const cli = fileURLToPath(new URL("./cli.ts", import.meta.url));
|
|
101
|
+
const { stdout, code, signal } = await new Promise<{
|
|
102
|
+
stdout: Buffer;
|
|
103
|
+
code: number | null;
|
|
104
|
+
signal: NodeJS.Signals | null;
|
|
105
|
+
}>((resolve, reject) => {
|
|
106
|
+
const child = spawn(
|
|
107
|
+
process.execPath,
|
|
108
|
+
["--experimental-strip-types", cli, "data", "--root", dir],
|
|
109
|
+
{ stdio: ["pipe", "pipe", "inherit"] },
|
|
110
|
+
);
|
|
111
|
+
const chunks: Buffer[] = [];
|
|
112
|
+
child.stdout.on("data", (c: Buffer) => chunks.push(c));
|
|
113
|
+
child.on("error", reject);
|
|
114
|
+
child.on("close", (code, signal) => resolve({ stdout: Buffer.concat(chunks), code, signal }));
|
|
115
|
+
child.stdin.end(JSON.stringify(query));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
assert.equal(signal, null, `CLI killed by signal ${signal}`);
|
|
119
|
+
assert.equal(code, 0, `CLI exited ${code}, expected 0`);
|
|
120
|
+
assert.equal(
|
|
121
|
+
stdout.byteLength,
|
|
122
|
+
expectedBytes,
|
|
123
|
+
`stdout truncated: got ${stdout.byteLength} bytes, expected ${expectedBytes}`,
|
|
124
|
+
);
|
|
125
|
+
const reply: unknown = JSON.parse(stdout.toString("utf8"));
|
|
126
|
+
assert.ok(isRecord(reply));
|
|
127
|
+
assert.equal(reply.ok, true);
|
|
128
|
+
assert.deepEqual(reply, { ok: true, ...result });
|
|
129
|
+
} finally {
|
|
130
|
+
await rm(dir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
package/src/cli.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
selectHost,
|
|
23
23
|
} from "./runtime/index.ts";
|
|
24
24
|
import { denoGlobal, processGlobal } from "./runtime/adapters/globals.ts";
|
|
25
|
+
import type { WritableStdio } from "./runtime/adapters/globals.ts";
|
|
25
26
|
import { errorMessage, isRecord } from "./runtime/core/guards.ts";
|
|
26
27
|
import { scaffold, slugify } from "create-urban-app";
|
|
27
28
|
import type { DataRequest } from "./runtime/index.ts";
|
|
@@ -446,6 +447,31 @@ export async function main(argv: string[]): Promise<number> {
|
|
|
446
447
|
}
|
|
447
448
|
}
|
|
448
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Flush a Node writable stdio stream, resolving once its buffered writes have drained to the OS.
|
|
452
|
+
* `process.stdout`/`stderr` connected to a *pipe* is asynchronous in Node: `console.log` buffers
|
|
453
|
+
* the write and an immediate `process.exit()` abandons anything not yet handed to the OS, so a
|
|
454
|
+
* large reply (> the 64 KB pipe buffer on Linux) is silently truncated. Writing an empty chunk and
|
|
455
|
+
* waiting for its completion callback drains the prior writes first. Deno's stdio has no such
|
|
456
|
+
* `write(chunk, cb)` shape (its `console.log` is synchronous), so its absence is a no-op flush.
|
|
457
|
+
*/
|
|
458
|
+
function flushStream(stream: WritableStdio | undefined): Promise<void> {
|
|
459
|
+
return new Promise((resolve) => {
|
|
460
|
+
if (!stream || typeof stream.write !== "function") {
|
|
461
|
+
resolve();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
stream.write("", () => resolve());
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Drain both stdout and stderr before an explicit `process.exit` so no buffered output is lost. */
|
|
469
|
+
async function flushStdio(): Promise<void> {
|
|
470
|
+
const p = processGlobal();
|
|
471
|
+
if (!p) return;
|
|
472
|
+
await Promise.all([flushStream(p.stdout), flushStream(p.stderr)]);
|
|
473
|
+
}
|
|
474
|
+
|
|
449
475
|
const proc = processGlobal();
|
|
450
476
|
const deno = denoGlobal();
|
|
451
477
|
const importMetaMain: unknown = Reflect.get(import.meta, "main");
|
|
@@ -462,11 +488,15 @@ if (importMetaMain === true || nodeMain) {
|
|
|
462
488
|
else if (deno?.exit) deno.exit(code);
|
|
463
489
|
};
|
|
464
490
|
main(argv).then(
|
|
465
|
-
(code) => {
|
|
466
|
-
if (code >= 0)
|
|
491
|
+
async (code) => {
|
|
492
|
+
if (code >= 0) {
|
|
493
|
+
await flushStdio();
|
|
494
|
+
exit(code);
|
|
495
|
+
}
|
|
467
496
|
},
|
|
468
|
-
(err) => {
|
|
497
|
+
async (err) => {
|
|
469
498
|
console.error(errorMessage(err));
|
|
499
|
+
await flushStdio();
|
|
470
500
|
exit(1);
|
|
471
501
|
},
|
|
472
502
|
);
|
|
@@ -8,10 +8,22 @@ export interface DenoRuntimeGlobal {
|
|
|
8
8
|
addSignalListener?: (sig: string, cb: () => void) => void;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* The subset of a Node writable stdio stream (`process.stdout` / `process.stderr`) we depend on:
|
|
13
|
+
* a `write` that accepts a completion callback fired once the chunk has flushed to the OS. Deno's
|
|
14
|
+
* stdio streams do not expose this shape; callers treat its absence as "already synchronous, no
|
|
15
|
+
* flush needed" (see `flushStdio` in cli.ts).
|
|
16
|
+
*/
|
|
17
|
+
export interface WritableStdio {
|
|
18
|
+
write?: (chunk: string, cb: () => void) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
export interface ProcessRuntimeGlobal {
|
|
12
22
|
argv?: string[];
|
|
13
23
|
exit?: (code: number) => void;
|
|
14
24
|
stdin?: AsyncIterable<Uint8Array | string> & { setEncoding?(encoding: string): void };
|
|
25
|
+
stdout?: WritableStdio;
|
|
26
|
+
stderr?: WritableStdio;
|
|
15
27
|
on?: (sig: string, cb: () => void) => void;
|
|
16
28
|
}
|
|
17
29
|
|
|
@@ -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",
|