Skip to content

Commit 13c9570

Browse files
fix: address review threads on the legacy-removal PRs (#850)
* fix(entry,state): accept CommonJS server factories and pin journal nullability `scanEntryExportsSource` now counts a top-level `module.exports = <expr>` as a default export, so AB4730 no longer rejects a `.cjs` stdio MCP entry that the generated lifecycle shell can run: the shell imports the entry namespace and the bundler exposes `module.exports` as its `default`. `exports.foo` and `module.exports.foo` stay named. The SQLite state driver's generic table check now compares each column's NOT NULL flag from `PRAGMA table_info` against the CREATE TABLE statements, so a journal whose `result_state` is nullable fails at open with the existing typed `corrupt` error instead of on the first NULL row. Docs record both, plus the resource/app contract fixture shape, in en and zh. * chore: link changeset to #850 * fix(config): scope CommonJS factory detection to AB4730 and skip a local module binding
1 parent 29c312c commit 13c9570

15 files changed

Lines changed: 264 additions & 20 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"agent-bundle": patch
3+
"@agent-bundle/runtime": patch
4+
---
5+
6+
Accept a CommonJS `module.exports` server factory as the default export of a
7+
stdio MCP entry, so `AB4730` no longer rejects a `.cjs` entry the generated
8+
lifecycle shell can run, and reject a SQLite state store at open with the
9+
typed `corrupt` error when its journal schema differs in column nullability
10+
from the one the current kernel writes. (#850)

‎docs/diagnostics.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -647,8 +647,12 @@ A local MCP server entry module (explicit `entry:` or the conventional
647647
in the framework stdio lifecycle shell (console-to-stderr guard,
648648
SIGINT/SIGTERM, stdin-EOF exit, bounded shutdown, heartbeat), and the shell
649649
calls the module's default export to build the server, so a module without
650-
one cannot be built. The detection is the same static default-export scan the
651-
build uses, so the diagnostic and the build always agree.
650+
one cannot be built. Validation finds the default export with a static scan of
651+
the entry's top-level statements. A CommonJS entry's top-level
652+
`module.exports = <factory>` counts as that default export, because the
653+
bundler exposes the assigned value as the module's `default`, unless the file
654+
declares its own `module` binding. A narrower `exports.foo = …` or
655+
`module.exports.foo = …` does not count.
652656

653657
Recover: default-export the server factory from the entry module, or declare
654658
a prebuilt server with `command` or `url`, which the framework launches

‎docs/entry-conventions.md‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -713,7 +713,9 @@ real child process's probe (two pipes). A test that wants other values injects
713713

714714
Source validation reports `AB4730` as an **error** when a local stdio MCP
715715
entry has no default export: the framework lifecycle shell calls that export
716-
to build the server, so such a module cannot be built. It reports
716+
to build the server, so such a module cannot be built. A CommonJS entry may
717+
assign the factory to `module.exports` instead; the bundler exposes that
718+
value as the module's `default`, and the scan counts it. It reports
717719
**informational** nudges (never errors) when `src/cli.ts`, `src/index.ts`, or
718720
`src/mcp/<server-id>.ts` exists but explicit configuration shadows it
719721
(`AB4731`/`AB4732`/`AB4733`). `bin: false` / `lib: false` opt-outs stay
@@ -1074,7 +1076,10 @@ name).
10741076

10751077
A module that constructs and connects a transport at top level without a
10761078
default export cannot be built: source validation reports `AB4730` as an
1077-
error. A server the framework should launch as-is instead of compiling is
1079+
error. A CommonJS entry's top-level `module.exports = <factory>` is that
1080+
default export under bundling; `exports.foo = …` and `module.exports.foo = …`
1081+
are named and do not satisfy it. A server the framework should launch as-is
1082+
instead of compiling is
10781083
declared with `command` or `url`, or as a `{ prebuilt: ... }` entry the
10791084
consumer's own build produced. The operator `.env` layer (#469) comes from
10801085
`agent-bundle/launch-env`, which the shell's prelude applies; that module is

‎packages/agent-bundle/src/build/entry-exports.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,46 @@ export interface EntryExportScan {
1717
const hasModifier = (statement: ts.Statement, kind: ts.SyntaxKind): boolean =>
1818
ts.canHaveModifiers(statement) && (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === kind);
1919

20+
const assignsModuleExports = (statement: ts.Statement): boolean => {
21+
if (!ts.isExpressionStatement(statement)) return false;
22+
const assignment = statement.expression;
23+
return ts.isBinaryExpression(assignment)
24+
&& assignment.operatorToken.kind === ts.SyntaxKind.EqualsToken
25+
&& ts.isPropertyAccessExpression(assignment.left)
26+
&& ts.isIdentifier(assignment.left.expression)
27+
&& assignment.left.expression.text === 'module'
28+
&& assignment.left.name.text === 'exports';
29+
};
30+
31+
const bindsModule = (statement: ts.Statement): boolean => {
32+
if (ts.isVariableStatement(statement)) {
33+
return statement.declarationList.declarations.some((declaration) =>
34+
ts.isIdentifier(declaration.name) && declaration.name.text === 'module');
35+
}
36+
if (ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) return statement.name?.text === 'module';
37+
if (ts.isImportDeclaration(statement)) {
38+
const clause = statement.importClause;
39+
if (clause === undefined) return false;
40+
if (clause.name?.text === 'module') return true;
41+
const bindings = clause.namedBindings;
42+
if (bindings === undefined) return false;
43+
return ts.isNamespaceImport(bindings)
44+
? bindings.name.text === 'module'
45+
: bindings.elements.some((element) => element.name.text === 'module');
46+
}
47+
return false;
48+
};
49+
50+
/**
51+
* A CommonJS module's top-level `module.exports = <expr>`, which the bundler
52+
* exposes as the namespace's `default`. A file that binds its own `module`
53+
* never qualifies.
54+
*/
55+
export const assignsModuleExportsSource = (source: string, fileName = 'entry.js'): boolean => {
56+
const { statements } = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false);
57+
return statements.some(assignsModuleExports) && !statements.some(bindsModule);
58+
};
59+
2060
const declaresMain = (statement: ts.Statement): boolean => {
2161
if (ts.isFunctionDeclaration(statement)) return statement.name?.text === 'main';
2262
if (ts.isVariableStatement(statement)) {

‎packages/agent-bundle/src/config/validate.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } fr
33

44
import { capabilityIsSupported, cliBinCapability, webSurfaceCapability } from '../adapters/capability-state.ts';
55
import { builtInHostNames, isBuiltInHost } from '../adapters/composite-layout.ts';
6-
import { type EntryExportScan, scanEntryExportsSource } from '../build/entry-exports.ts';
6+
import { assignsModuleExportsSource, type EntryExportScan, scanEntryExportsSource } from '../build/entry-exports.ts';
77
import { externalizedSpecifiers } from '../build/external-policy.ts';
88
import { frameworkOwnedPluginCollisions, frameworkOwnedRsbuildPlugins } from '../build/framework-plugins.ts';
99
import type { CapabilityState } from '../core/capabilities.ts';
@@ -616,9 +616,8 @@ const relativePosix = toPosixRelative;
616616

617617
/**
618618
* AB4730: every local stdio entry is wrapped in the framework stdio lifecycle
619-
* shell, which imports the entry's default export as its server factory. The
620-
* detection is the same static export scan the build uses to build the wrap,
621-
* so the diagnostic and the build always agree.
619+
* shell, which imports the entry's default export as its server factory. A
620+
* CommonJS entry's top-level `module.exports` is that default under bundling.
622621
*/
623622
const missingServerFactoryErrors = (
624623
name: string,
@@ -633,7 +632,8 @@ const missingServerFactoryErrors = (
633632
: conventionalEntry;
634633
if (source === undefined || !bundleScriptExtensions.has(extname(source).toLowerCase())) return [];
635634
try {
636-
if (scanEntryExportsSource(readFileSync(source, 'utf8'), source).hasDefaultExport) return [];
635+
const text = readFileSync(source, 'utf8');
636+
if (scanEntryExportsSource(text, source).hasDefaultExport || assignsModuleExportsSource(text, source)) return [];
637637
} catch {
638638
// An unreadable entry is already reported by the existence diagnostics.
639639
return [];

‎packages/agent-bundle/tests/entry-shell.test.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import ts from 'typescript-5';
88
import { claudeAdapter } from '../src/adapters/claude.ts';
99
import { cursorHookWrapperSource, nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters/hook-contract.ts';
1010
import type { NoticeDeliveryAdvertisement } from '../src/adapters/notice-delivery.ts';
11-
import { scanEntryExportsSource } from '../src/build/entry-exports.ts';
11+
import { assignsModuleExportsSource, scanEntryExportsSource } from '../src/build/entry-exports.ts';
1212
import * as entryShellModule from '../src/build/entry-shell.ts';
1313
import { launchEnvLayerSpecifier, operatorEnvLayerImport, operatorEnvLayerModuleSource, operatorEnvLayerVirtualModule } from '../src/build/launch-env-shell.ts';
1414
import { stableJson } from '../src/core/digest.ts';
@@ -59,6 +59,22 @@ describe('entry export scanning', () => {
5959
expect(scanEntryExportsSource('export type { main } from "./types.ts";').hasMainExport).toBe(false);
6060
});
6161

62+
it('keeps module.exports out of the shared export scan', () => {
63+
expect(scanEntryExportsSource('module.exports = () => server;', '/app/entry.cjs')).toEqual({
64+
hasDefaultExport: false,
65+
hasMainExport: false,
66+
});
67+
});
68+
69+
it('recognizes only a top-level module.exports assignment to an unbound module', () => {
70+
expect(assignsModuleExportsSource('module.exports = () => server;', '/app/entry.cjs')).toBe(true);
71+
expect(assignsModuleExportsSource('exports.foo = () => server;', '/app/entry.cjs')).toBe(false);
72+
expect(assignsModuleExportsSource('module.exports.foo = 1;', '/app/entry.cjs')).toBe(false);
73+
expect(assignsModuleExportsSource('if (ok) { module.exports = 1; }', '/app/entry.cjs')).toBe(false);
74+
expect(assignsModuleExportsSource('const module = { exports: null };\nmodule.exports = factory;', '/app/entry.mts')).toBe(false);
75+
expect(assignsModuleExportsSource("import module from './m.ts';\nmodule.exports = factory;", '/app/entry.ts')).toBe(false);
76+
});
77+
6278
it('never matches inside comments, strings, or template literals', () => {
6379
expect(scanEntryExportsSource('// export default nothing\nconst a = 1;').hasDefaultExport).toBe(false);
6480
expect(scanEntryExportsSource('/* export const main = 1 */ const a = 1;').hasMainExport).toBe(false);

‎packages/agent-bundle/tests/mcp.test.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1444,6 +1444,52 @@ it('creates session state only after setup succeeds and always inherits the stdi
14441444
}
14451445
}, 30_000);
14461446

1447+
it('accepts a CommonJS stdio entry whose server factory is module.exports, and runs it', async () => {
1448+
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-cjs-entry-'));
1449+
try {
1450+
await mkdir(join(root, 'src'), { recursive: true });
1451+
await mkdir(join(root, 'node_modules'), { recursive: true });
1452+
await symlink(
1453+
join(agentBundleNodeModules, '@modelcontextprotocol'),
1454+
join(root, 'node_modules', '@modelcontextprotocol'),
1455+
'dir',
1456+
);
1457+
await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n');
1458+
await writeFile(join(root, 'package.json'), '{"type":"module"}\n');
1459+
await writeFile(join(root, 'src', 'server.cjs'), [
1460+
"const { McpServer } = require('@modelcontextprotocol/server');",
1461+
'',
1462+
'module.exports = () => {',
1463+
" const server = new McpServer({ name: 'cjs-server', version: '1.0.0' });",
1464+
" server.registerTool('ping', { description: 'Answer a ping.' }, async () => ({",
1465+
" content: [{ type: 'text', text: 'pong' }],",
1466+
' }));',
1467+
' return server;',
1468+
'};',
1469+
'',
1470+
].join('\n'));
1471+
const config: AgentBundleConfig = {
1472+
mcp: { servers: { cjs: { entry: './src/server.cjs' } } },
1473+
plugin: { name: 'mcp-cjs-fixture' },
1474+
targets: ['portable'],
1475+
};
1476+
// The shell reads the entry's `default`, which is what the bundler makes
1477+
// of `module.exports`; AB4730 must not refuse an entry it can run.
1478+
expect(validateSource(loadedProject(root, config), { skills: [] }, registry)).toEqual([]);
1479+
1480+
const model = await normalizeProject(loadedProject(root, config), { skills: [] }, registry);
1481+
const artifact = join(root, 'dist');
1482+
await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph });
1483+
1484+
await expect(new McpService().list({ artifact, server: 'cjs', target: 'portable' })).resolves.toMatchObject({
1485+
server: { name: 'cjs-server', version: '1.0.0' },
1486+
tools: [{ name: 'ping' }],
1487+
});
1488+
} finally {
1489+
await removeTree(root);
1490+
}
1491+
}, 30_000);
1492+
14471493
it('serves compiler-bundled MCP App resources from a copied artifact without project source', async () => {
14481494
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-resource-'));
14491495
const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-app-consumer-'));

‎packages/rsc-runtime/src/state/sqlite.ts‎

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -100,18 +100,40 @@ const COMPACTED_KERNEL_FORMAT = 2;
100100
const READABLE_KERNEL_FORMATS: readonly number[] = Object.freeze([KERNEL_FORMAT, COMPACTED_KERNEL_FORMAT]);
101101

102102
/**
103-
* Column order of every table `initialize` creates. A pre-existing table
104-
* whose columns differ is not this kernel's schema and fails closed as
105-
* `corrupt` instead of surfacing a raw SQLite error on the first statement
106-
* that names a missing column.
103+
* Column order and nullability of every table `initialize` creates, written
104+
* as the `PRAGMA table_info` signature each `CREATE TABLE` below produces. A
105+
* pre-existing table whose columns differ is not this kernel's schema and
106+
* fails closed as `corrupt` instead of surfacing a raw SQLite error on the
107+
* first statement that names a missing column. Nullability is part of that
108+
* identity: a column this kernel reads unconditionally but an earlier layout
109+
* left nullable holds rows it cannot decode, and those must fail at open
110+
* rather than on the row that happens to be NULL.
107111
*/
108112
const TABLE_COLUMNS: Readonly<Record<string, readonly string[]>> = Object.freeze({
109-
agent_state_head: ['id', 'revision', 'state'],
110-
agent_state_journal: ['revision', 'kind', 'name', 'payload', 'state', 'result_state', 'to_version', 'idempotency_key', 'committed_at'],
111-
agent_state_meta: ['id', 'definition_id', 'schema_version', 'kernel_format'],
112-
agent_state_pruned_keys: ['idempotency_key', 'revision', 'canonical_input'],
113+
agent_state_head: ['id', 'revision NOT NULL', 'state NOT NULL'],
114+
agent_state_journal: [
115+
'revision',
116+
'kind NOT NULL',
117+
'name',
118+
'payload',
119+
'state',
120+
'result_state NOT NULL',
121+
'to_version',
122+
'idempotency_key NOT NULL',
123+
'committed_at NOT NULL',
124+
],
125+
agent_state_meta: ['id', 'definition_id NOT NULL', 'schema_version NOT NULL', 'kernel_format NOT NULL'],
126+
agent_state_pruned_keys: ['idempotency_key', 'revision NOT NULL', 'canonical_input NOT NULL'],
113127
});
114128

129+
interface TableInfoRow {
130+
readonly name: string;
131+
readonly notnull: number;
132+
}
133+
134+
const columnSignature = (column: TableInfoRow): string =>
135+
column.notnull === 0 ? column.name : `${column.name} NOT NULL`;
136+
115137
export interface SqliteStateDriverOptions {
116138
/**
117139
* SQLite lock wait budget per operation in milliseconds (default 5000).
@@ -848,8 +870,8 @@ class SqliteStore<TState, TEvents extends AgentStateEventSchemas> implements Age
848870
`);
849871
const definition = this.#definition;
850872
for (const [table, columns] of Object.entries(TABLE_COLUMNS)) {
851-
const actual = (transactionDb.prepare(`PRAGMA table_info(${table})`).all() as unknown as { readonly name: string }[])
852-
.map((column) => column.name);
873+
const actual = (transactionDb.prepare(`PRAGMA table_info(${table})`).all() as unknown as TableInfoRow[])
874+
.map(columnSignature);
853875
if (actual.join(',') !== columns.join(',')) {
854876
throw new AgentStateError(
855877
'corrupt',

‎packages/rsc-runtime/tests/state-sqlite.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,39 @@ describe('sqlite driver storage behavior', () => {
189189
});
190190
}));
191191

192+
it('fails closed with a typed corrupt error when a column that must be NOT NULL is nullable', () =>
193+
withRoot(async (root) => {
194+
const file = join(root, 'state.sqlite');
195+
const store = await createSqliteStateDriver({ file }).open(counterDefinition());
196+
await store.dispatch('bumped', { by: 1 }, { idempotencyKey: 'k1' });
197+
await store.close();
198+
const db = new DatabaseSync(file);
199+
// Same column names in the same order, so only the NOT NULL flags tell
200+
// this journal apart from the one the current kernel writes.
201+
db.exec(`
202+
ALTER TABLE agent_state_journal RENAME TO agent_state_journal_old;
203+
CREATE TABLE agent_state_journal (
204+
revision INTEGER PRIMARY KEY,
205+
kind TEXT NOT NULL CHECK (kind IN ('event', 'reset', 'migrate')),
206+
name TEXT,
207+
payload TEXT,
208+
state TEXT,
209+
result_state TEXT,
210+
to_version INTEGER,
211+
idempotency_key TEXT NOT NULL UNIQUE,
212+
committed_at TEXT NOT NULL
213+
);
214+
INSERT INTO agent_state_journal SELECT * FROM agent_state_journal_old;
215+
DROP TABLE agent_state_journal_old;
216+
`);
217+
db.close();
218+
await expect(createSqliteStateDriver({ file }).open(counterDefinition())).rejects.toMatchObject({
219+
code: 'corrupt',
220+
message: expect.stringContaining('table agent_state_journal') as string,
221+
name: 'AgentStateError',
222+
});
223+
}));
224+
192225
it('rejects a pending open when the driver closes before initialization resumes', () =>
193226
withRoot(async (root) => {
194227
const driver = createSqliteStateDriver({ root });

‎website/docs/en/guide/authoring/mcp.mdx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,19 @@ sixty-second activity throttle, labeled with the server name).
571571
That guard matters because stdout carries JSON-RPC framing: one stray `console.log` from any
572572
imported module would corrupt the protocol stream.
573573
574+
A CommonJS entry assigns the same factory to `module.exports`, which the bundler exposes as the
575+
module's default export:
576+
577+
```js
578+
// src/mcp/curator.cjs
579+
const { McpServer } = require('@modelcontextprotocol/server');
580+
581+
module.exports = () => new McpServer({ name: 'curator', version: '1.0.0' });
582+
```
583+
584+
Only the whole-object assignment counts. `exports.curator = …` and `module.exports.curator = …`
585+
are named exports, and the shell reads neither.
586+
574587
A module that constructs and connects a transport at top level without a default export cannot be
575588
built: source validation reports `AB4730` as an **error**. Declare a server you do not want
576589
compiled with `command` or `url`, or as a `{ prebuilt: ... }` entry your own build produced.

0 commit comments

Comments
 (0)