diff --git a/packages/mcp-server/src/legacy-schema.test.ts b/packages/mcp-server/src/legacy-schema.test.ts new file mode 100644 index 00000000..2730769e --- /dev/null +++ b/packages/mcp-server/src/legacy-schema.test.ts @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { CURRENT_SCHEMA_VERSION } from '@openchatlab/core' +import { DatabaseManager, NodePathProvider, readDataDirCompatibilityMeta } from '@openchatlab/node-runtime' +import { startMcpServer } from './server' +import { openTestSqliteDatabase } from '../../../tests/helpers/sqlite.mts' + +test('real MCP tools and resources reject a released schema-v4 database until a writable upgrade', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-v4-preflight-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const dataDir = path.join(root, 'data') + const dbDir = path.join(dataDir, 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'legacy-v4.db') + + // Matches the published v4 member.name/nickname layout covered by + // database-manager.test.ts, rather than a new-schema fixture with a v4 tag. + const fixture = openTestSqliteDatabase(dbPath) + try { + fixture.exec(` + CREATE TABLE meta ( + name TEXT NOT NULL, platform TEXT NOT NULL, type TEXT NOT NULL, + imported_at INTEGER NOT NULL, schema_version INTEGER DEFAULT 4 + ); + INSERT INTO meta (name, platform, type, imported_at, schema_version) + VALUES ('Old Chat', 'qq', 'group', 1000, 4); + CREATE TABLE member ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_id TEXT NOT NULL UNIQUE, name TEXT NOT NULL, nickname TEXT + ); + INSERT INTO member (platform_id, name, nickname) + VALUES ('u1', 'Alice Account', 'Alice Group'); + CREATE TABLE message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sender_id INTEGER NOT NULL, ts INTEGER NOT NULL, type INTEGER NOT NULL, content TEXT + ); + INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'hello'); + CREATE TABLE chat_session ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, end_ts INTEGER NOT NULL, + message_count INTEGER DEFAULT 0, is_manual INTEGER DEFAULT 0, summary TEXT + ); + CREATE TABLE message_context ( + message_id INTEGER PRIMARY KEY, session_id INTEGER NOT NULL, topic_id INTEGER + ); + `) + } finally { + fixture.close() + } + + const beforeDb = fs.readFileSync(dbPath) + const markerPath = path.join(dataDir, '.chatlab-meta.json') + assert.equal(readDataDirCompatibilityMeta(dataDir), null) + + const dbManager = new DatabaseManager(new NodePathProvider(dataDir), { + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + t.after(() => dbManager.closeAll()) + + // The shared read-only API is also used by CLI auto-import analysis. It must + // continue to discover genuine v4 sessions without requiring MCP's v10 schema. + assert.deepEqual(dbManager.listSessionIdsReadonly(), ['legacy-v4']) + const sharedDb = dbManager.openReadonly('legacy-v4') + assert.ok(sharedDb) + assert.equal(sharedDb.readonly, true) + sharedDb.close() + assert.deepEqual(fs.readFileSync(dbPath), beforeDb) + assert.equal(fs.existsSync(markerPath), false) + + const client = new Client({ name: 'legacy-schema-test-client', version: '1.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + // Exercise the actual registration and resource callbacks, substituting only + // in-memory transport for stdio as in the protocol contract test above. + const connect = McpServer.prototype.connect + t.mock.method(McpServer.prototype, 'connect', function (this: McpServer) { + t.after(() => this.close()) + return connect.call(this, serverTransport) + }) + await startMcpServer({ version: '0.37.6', dbManager }) + await client.connect(clientTransport) + try { + for (const [name, args] of [ + ['chatlab_list_sessions', {}], + ['chatlab_get_session_info', { session_id: 'legacy-v4' }], + ['chatlab_get_recent_messages', { session_id: 'legacy-v4' }], + ['chatlab_execute_sql', { session_id: 'legacy-v4', sql: 'SELECT 1' }], + ] as const) { + const result = await client.callTool({ name, arguments: { ...args, format: 'json' } }) + assert.equal(result.isError, true, `${name} should require a schema upgrade`) + assert.match(JSON.stringify(result.content), /schema v4.*schema v10.*migrate/i) + } + for (const uri of [ + 'chatlab://sessions', + 'chatlab://sessions/legacy-v4/meta', + 'chatlab://sessions/legacy-v4/schema', + ]) { + await assert.rejects(client.readResource({ uri }), /schema v4.*schema v10.*migrate/i, uri) + } + assert.deepEqual(fs.readFileSync(dbPath), beforeDb) + assert.equal(fs.existsSync(markerPath), false, 'MCP must not repair the compatibility marker') + + // This explicit writable operation is intentionally outside MCP dispatch. + const migrated = dbManager.open('legacy-v4') + assert.ok(migrated) + assert.equal( + (migrated.prepare('SELECT schema_version FROM meta').get() as { schema_version: number }).schema_version, + CURRENT_SCHEMA_VERSION + ) + dbManager.close('legacy-v4') + const afterUpgrade = fs.readFileSync(dbPath) + const repairedMarker = fs.readFileSync(markerPath) + + const list = await client.callTool({ name: 'chatlab_list_sessions', arguments: { format: 'json' } }) + assert.equal(list.isError, undefined, JSON.stringify(list.content)) + assert.match(JSON.stringify(list.content), /Old Chat/) + const info = await client.callTool({ + name: 'chatlab_get_session_info', + arguments: { session_id: 'legacy-v4', format: 'json' }, + }) + assert.equal(info.isError, undefined, JSON.stringify(info.content)) + + const listResource = await client.readResource({ uri: 'chatlab://sessions' }) + assert.match(JSON.stringify(listResource.contents), /Old Chat/) + const metaResource = await client.readResource({ uri: 'chatlab://sessions/legacy-v4/meta' }) + assert.match(JSON.stringify(metaResource.contents), /Old Chat/) + const schemaResource = await client.readResource({ uri: 'chatlab://sessions/legacy-v4/schema' }) + assert.match(JSON.stringify(schemaResource.contents), /member/) + assert.deepEqual(fs.readFileSync(dbPath), afterUpgrade) + assert.deepEqual(fs.readFileSync(markerPath), repairedMarker) + } finally { + await client.close() + } +}) + +test('MCP tools and resources refuse invalid SQLite schema versions before SQL dispatch', async (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-invalid-schema-')) + t.after(() => fs.rmSync(root, { recursive: true, force: true })) + const dataDir = path.join(root, 'data') + const dbDir = path.join(dataDir, 'databases') + fs.mkdirSync(dbDir, { recursive: true }) + const dbPath = path.join(dbDir, 'invalid-version.db') + const fixture = openTestSqliteDatabase(dbPath) + try { + fixture.exec(` + CREATE TABLE meta ( + name TEXT, platform TEXT, type TEXT, imported_at INTEGER, schema_version INTEGER + ); + INSERT INTO meta VALUES ('Legacy', 'qq', 'group', 1000, 'not-a-version'); + CREATE TABLE member (id INTEGER, platform_id TEXT, name TEXT, nickname TEXT); + INSERT INTO member VALUES (1, 'legacy-user', 'Alice', 'Alice Group'); + CREATE TABLE message (id INTEGER, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT); + INSERT INTO message VALUES (1, 1, 1000, 0, 'legacy message'); + `) + const actual = fixture.prepare('SELECT schema_version, typeof(schema_version) AS storage FROM meta').get() as { + schema_version: unknown + storage: string + } + assert.equal(actual.schema_version, 'not-a-version') + assert.equal(actual.storage, 'text') + } finally { + fixture.close() + } + + const originalDb = fs.readFileSync(dbPath) + const markerPath = path.join(dataDir, '.chatlab-meta.json') + assert.equal(readDataDirCompatibilityMeta(dataDir), null) + + const dbManager = new DatabaseManager(new NodePathProvider(dataDir), { + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + t.after(() => dbManager.closeAll()) + // CLI import analysis must retain read-only access to older/unrecognized + // schema candidates, without silently applying MCP's stricter rule. + assert.deepEqual(dbManager.listSessionIdsReadonly(), ['invalid-version']) + const sharedDb = dbManager.openReadonly('invalid-version') + assert.ok(sharedDb) + assert.equal(sharedDb.readonly, true) + sharedDb.close() + + const client = new Client({ name: 'invalid-schema-test-client', version: '1.0.0' }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const connect = McpServer.prototype.connect + t.mock.method(McpServer.prototype, 'connect', function (this: McpServer) { + t.after(() => this.close()) + return connect.call(this, serverTransport) + }) + await startMcpServer({ version: '0.37.6', dbManager }) + await client.connect(clientTransport) + try { + for (const [name, args] of [ + ['chatlab_list_sessions', {}], + ['chatlab_execute_sql', { session_id: 'invalid-version', sql: 'SELECT 1 AS ok' }], + ['chatlab_get_session_info', { session_id: 'invalid-version' }], + ['chatlab_get_schema', { session_id: 'invalid-version' }], + ] as const) { + const result = await client.callTool({ name, arguments: { ...args, format: 'json' } }) + assert.equal(result.isError, true, `${name} incorrectly accepted an invalid schema version`) + assert.match(JSON.stringify(result.content), /invalid schema version/i, name) + assert.doesNotMatch(JSON.stringify(result.content), /no such column/i, name) + } + + for (const uri of [ + 'chatlab://sessions', + 'chatlab://sessions/invalid-version/meta', + 'chatlab://sessions/invalid-version/schema', + ]) { + await assert.rejects(client.readResource({ uri }), /invalid schema version/i, uri) + } + assert.deepEqual(fs.readFileSync(dbPath), originalDb) + assert.equal(fs.existsSync(markerPath), false) + } finally { + await client.close() + } +}) diff --git a/packages/mcp-server/src/server.test.ts b/packages/mcp-server/src/server.test.ts index 13037a5e..7255312f 100644 --- a/packages/mcp-server/src/server.test.ts +++ b/packages/mcp-server/src/server.test.ts @@ -23,7 +23,7 @@ test('advertises every ChatLab MCP query tool as read-only', async () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() try { - registerTools(server, { listSessionIdsReadonly: () => [], openReadonly: () => null }) + registerTools(server, { listSessionIdsMcpReadonly: () => [], openMcpReadonly: () => null }) await server.connect(serverTransport) await client.connect(clientTransport) @@ -49,7 +49,7 @@ test('advertises every ChatLab MCP query tool as read-only', async () => { } }) -test('MCP queries do not migrate a pending-schema session or raise the data compatibility gate', async (t) => { +test('MCP rejects schema v5 before dispatch without migrating or raising the compatibility gate', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-readonly-')) t.after(() => fs.rmSync(root, { recursive: true, force: true })) @@ -116,7 +116,8 @@ test('MCP queries do not migrate a pending-schema session or raise the data comp ['chatlab_execute_sql', { session_id: 'legacy', sql: 'SELECT content FROM message' }], ] as const) { const result = await client.callTool({ name, arguments: { ...args, format: 'json' } }) - assert.equal(result.isError, undefined, `${name}: ${JSON.stringify(result.content)}`) + assert.equal(result.isError, true, `${name} should require an explicit upgrade`) + assert.match(JSON.stringify(result.content), /schema v5.*schema v10.*migrate/i) assert.equal(readDataDirCompatibilityMeta(dataDir), null, `${name} raised the compatibility gate`) assert.deepEqual(fs.readFileSync(dbPath), initialDatabase, `${name} modified the legacy database`) } @@ -135,7 +136,7 @@ test('MCP queries do not migrate a pending-schema session or raise the data comp } }) -test('segment summaries handle v9 pending schema without migration and preserve v10 results', async (t) => { +test('MCP requires an explicit v9 upgrade and preserves current v10 summary results', async (t) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-v9-summaries-')) t.after(() => fs.rmSync(root, { recursive: true, force: true })) const dataDir = path.join(root, 'data') @@ -216,8 +217,8 @@ test('segment summaries handle v9 pending schema without migration and preserve name: 'chatlab_get_segment_summaries', arguments: { session_id: 'v9', format: 'json' }, }) - assert.equal(oldResult.isError, undefined, JSON.stringify(oldResult.content)) - assert.match((oldResult.content as Array<{ text: string }>)[0].text, /No segments with summaries found/) + assert.equal(oldResult.isError, true, JSON.stringify(oldResult.content)) + assert.match(JSON.stringify(oldResult.content), /schema v9.*schema v10.*migrate/i) assert.deepEqual(fs.readFileSync(legacyPath), beforeV9) assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), beforeGate) @@ -333,7 +334,8 @@ test('read-only MCP rejects schema v6 without a repaired compatibility gate', as name: 'chatlab_get_session_info', arguments: { session_id: 'v6', format: 'json' }, }) - assert.equal(result.isError, undefined, JSON.stringify(result.content)) + assert.equal(result.isError, true, JSON.stringify(result.content)) + assert.match(JSON.stringify(result.content), /schema v6.*schema v10.*migrate/i) assert.deepEqual(fs.readFileSync(dbPath), beforeDb) assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), gateAfterRepair) } finally { diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index b4f43771..c1f46ad5 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -60,9 +60,9 @@ export function registerTools(server: McpServer, dbManager: McpDatabaseManager, const context: SessionListContext = { db: null as any, sessionId: '', - listSessionIds: () => dbManager.listSessionIdsReadonly(), + listSessionIds: () => dbManager.listSessionIdsMcpReadonly(), openDb: (id) => { - const db = dbManager.openReadonly(id) + const db = dbManager.openMcpReadonly(id) if (db) openedDbs.push(db) return db }, @@ -92,7 +92,7 @@ export function registerTools(server: McpServer, dbManager: McpDatabaseManager, async (params) => { const sessionId = params.session_id as string const format = params.format as string | undefined - const db = dbManager.openReadonly(sessionId) + const db = dbManager.openMcpReadonly(sessionId) if (!db) { return { content: [{ type: 'text' as const, text: `Session not found: ${sessionId}` }], @@ -118,10 +118,10 @@ export function registerTools(server: McpServer, dbManager: McpDatabaseManager, function registerResources(server: McpServer, dbManager: McpDatabaseManager): void { server.resource('sessions-list', 'chatlab://sessions', { description: '所有已导入的聊天会话列表' }, async () => { - const sessionIds = dbManager.listSessionIdsReadonly() + const sessionIds = dbManager.listSessionIdsMcpReadonly() const sessions = sessionIds .map((id) => { - const db = dbManager.openReadonly(id) + const db = dbManager.openMcpReadonly(id) if (!db) return null try { const meta = getSessionMeta(db) @@ -150,7 +150,7 @@ function registerResources(server: McpServer, dbManager: McpDatabaseManager): vo { description: '会话元信息(名称、平台、消息数等)' }, async (uri, params) => { const sessionId = params.sessionId as string - const db = dbManager.openReadonly(sessionId) + const db = dbManager.openMcpReadonly(sessionId) if (!db) { return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] } } @@ -180,7 +180,7 @@ function registerResources(server: McpServer, dbManager: McpDatabaseManager): vo { description: '会话数据库的表结构' }, async (uri, params) => { const sessionId = params.sessionId as string - const db = dbManager.openReadonly(sessionId) + const db = dbManager.openMcpReadonly(sessionId) if (!db) { return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] } } diff --git a/packages/mcp-server/src/types.ts b/packages/mcp-server/src/types.ts index b6f74b37..74f6b084 100644 --- a/packages/mcp-server/src/types.ts +++ b/packages/mcp-server/src/types.ts @@ -9,10 +9,10 @@ import type { DatabaseAdapter } from '@openchatlab/core' * Both CLI's DatabaseManager and Desktop's helper can satisfy this contract. */ export interface McpDatabaseManager { - /** List chat sessions without running schema migrations. */ - listSessionIdsReadonly(): string[] - /** Open a non-cached, strictly read-only connection; caller must close it. */ - openReadonly(sessionId: string): DatabaseAdapter | null + /** List only MCP-supported chat sessions; reject any requiring an upgrade. */ + listSessionIdsMcpReadonly(): string[] + /** Open a non-cached, strictly read-only MCP connection after schema preflight; caller closes it. */ + openMcpReadonly(sessionId: string): DatabaseAdapter | null } export interface McpServerOptions { diff --git a/packages/node-runtime/src/database-manager.test.ts b/packages/node-runtime/src/database-manager.test.ts index 64133464..2a444c7c 100644 --- a/packages/node-runtime/src/database-manager.test.ts +++ b/packages/node-runtime/src/database-manager.test.ts @@ -1328,3 +1328,152 @@ test('session deletion rejects a live topic execution owned by another runtime', reopened.close() } }) + +test('read-only MCP schema preflight rejects all pending and future versions without touching database bytes', () => { + for (let version = 0; version <= CURRENT_SCHEMA_VERSION + 1; version++) { + const root = makeTempDir() + const provider = createPathProvider(root) + fs.mkdirSync(provider.getDatabaseDir(), { recursive: true }) + const dbPath = path.join(provider.getDatabaseDir(), 'schema-check.db') + const db = new Database(dbPath, { nativeBinding }) + try { + const versionColumn = version === 0 ? '' : ', schema_version INTEGER' + const versionValue = version === 0 ? '' : `, ${version}` + db.exec(` + CREATE TABLE meta (name TEXT, platform TEXT, type TEXT, imported_at INTEGER${versionColumn}); + INSERT INTO meta VALUES ('Schema Check', 'qq', 'group', 1000${versionValue}); + CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT); + CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT); + `) + } finally { + db.close() + } + + const manager = new DatabaseManager(provider, { + nativeBinding, + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + // Version 6+ requires an existing compatibility gate. In this test we + // establish it before read-only access so schema validation is isolated. + if (version >= 6) manager.raiseCurrentChatDbCompatibilityGate() + const beforeDb = fs.readFileSync(dbPath) + const markerPath = path.join(provider.getUserDataDir(), '.chatlab-meta.json') + const beforeMarker = fs.existsSync(markerPath) ? fs.readFileSync(markerPath) : null + + if (version === CURRENT_SCHEMA_VERSION) { + const opened = manager.openMcpReadonly('schema-check') + assert.ok(opened) + assert.equal(opened.readonly, true) + opened.close() + } else { + const guidance = version < CURRENT_SCHEMA_VERSION ? /migrate it first/ : /newer schema/ + assert.throws( + () => manager.openMcpReadonly('schema-check'), + (error: unknown) => + error instanceof Error && + error.message.includes(`schema v${version}`) && + error.message.includes(`schema v${CURRENT_SCHEMA_VERSION}`) && + guidance.test(error.message), + `unexpected schema v${version} should be rejected before query dispatch` + ) + } + + assert.deepEqual(fs.readFileSync(dbPath), beforeDb, `schema v${version} was modified`) + assert.deepEqual(fs.existsSync(markerPath) ? fs.readFileSync(markerPath) : null, beforeMarker) + manager.closeAll() + } +}) + +test('MCP reports future schemas accurately even if their compatibility marker is missing', () => { + const root = makeTempDir() + const provider = createPathProvider(root) + fs.mkdirSync(provider.getDatabaseDir(), { recursive: true }) + const dbPath = path.join(provider.getDatabaseDir(), 'future.db') + const db = new Database(dbPath, { nativeBinding }) + try { + db.exec(` + CREATE TABLE meta ( + name TEXT, platform TEXT, type TEXT, imported_at INTEGER, schema_version INTEGER + ); + INSERT INTO meta VALUES ('Future Chat', 'qq', 'group', 1000, ${CURRENT_SCHEMA_VERSION + 1}); + CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT); + CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT); + `) + } finally { + db.close() + } + const before = fs.readFileSync(dbPath) + const manager = new DatabaseManager(provider, { + nativeBinding, + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + assert.equal(readDataDirCompatibilityMeta(provider.getUserDataDir()), null) + + assert.throws( + () => manager.openMcpReadonly('future'), + (error: unknown) => + error instanceof Error && + /newer schema/i.test(error.message) && + /Upgrade the ChatLab runtime/i.test(error.message) && + !/compatibility marker/i.test(error.message) + ) + assert.throws(() => manager.listSessionIdsMcpReadonly(), /Upgrade the ChatLab runtime/i) + assert.deepEqual(fs.readFileSync(dbPath), before) + assert.equal(readDataDirCompatibilityMeta(provider.getUserDataDir()), null) + manager.closeAll() +}) + +test('MCP schema preflight rejects invalid SQLite version values without affecting shared reads', () => { + const cases: Array<{ label: string; sqlValue: string; sqlType: string }> = [ + { label: 'text', sqlValue: "'not-a-version'", sqlType: 'text' }, + { label: 'fractional', sqlValue: '10.5', sqlType: 'real' }, + { label: 'negative', sqlValue: '-1', sqlType: 'integer' }, + { label: 'unsafe integer', sqlValue: '9223372036854775807', sqlType: 'integer' }, + { label: 'blob', sqlValue: "X'616263'", sqlType: 'blob' }, + ] + for (const { label, sqlValue, sqlType } of cases) { + const root = makeTempDir() + const provider = createPathProvider(root) + fs.mkdirSync(provider.getDatabaseDir(), { recursive: true }) + const dbPath = path.join(provider.getDatabaseDir(), 'invalid-version.db') + const fixture = new Database(dbPath, { nativeBinding }) + try { + fixture.exec(` + CREATE TABLE meta ( + name TEXT, platform TEXT, type TEXT, imported_at INTEGER, + schema_version INTEGER + ); + INSERT INTO meta VALUES ('Legacy', 'qq', 'group', 1000, ${sqlValue}); + CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT, name TEXT, nickname TEXT); + CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT); + `) + const stored = fixture.prepare('SELECT typeof(schema_version) AS storage FROM meta').get() as { + storage: string + } + assert.equal(stored.storage, sqlType, label) + } finally { + fixture.close() + } + + const before = fs.readFileSync(dbPath) + const manager = new DatabaseManager(provider, { + nativeBinding, + runtime: { version: '0.37.6', kind: 'mcp' }, + }) + try { + // CLI auto-import relies on shared read-only access to legacy candidates. + assert.deepEqual(manager.listSessionIdsReadonly(), ['invalid-version'], label) + const shared = manager.openReadonly('invalid-version') + assert.ok(shared) + shared.close() + + assert.throws(() => manager.openMcpReadonly('invalid-version'), /invalid schema version/i, label) + assert.throws(() => manager.listSessionIdsMcpReadonly(), /invalid schema version/i, label) + assert.deepEqual(fs.readFileSync(dbPath), before, `${label}: read must not alter the DB`) + assert.equal(readDataDirCompatibilityMeta(provider.getUserDataDir()), null, label) + } finally { + manager.closeAll() + fs.rmSync(root, { recursive: true, force: true }) + } + } +}) diff --git a/packages/node-runtime/src/database-manager.ts b/packages/node-runtime/src/database-manager.ts index eaec3d99..99e923ec 100644 --- a/packages/node-runtime/src/database-manager.ts +++ b/packages/node-runtime/src/database-manager.ts @@ -133,16 +133,53 @@ export class DatabaseManager { } /** - * Open an uncached session database strictly for reading, without migrations or - * compatibility gate updates. The caller owns and must close the connection. + * Open an uncached session database strictly for reading, without migrations. + * This shared API is also used by CLI import analysis: do not require the + * current schema here. The caller owns and must close the connection. */ openReadonly(sessionId: string): DatabaseAdapter | null { this.assertCompatible() if (!fs.existsSync(this.getDbPath(sessionId))) return null + return this.openRawSessionDatabase(sessionId, { readonly: true }) + } + + /** + * MCP-only strict read access. Never migrate or repair data. Older schema + * versions require an explicit upgrade through a writable ChatLab runtime. + */ + openMcpReadonly(sessionId: string): DatabaseAdapter | null { + const db = this.openReadonly(sessionId) + if (!db) return null - const db = this.openRawSessionDatabase(sessionId, { readonly: true }) try { - if (isChatSessionDb(db)) this.assertReadonlyCompatibilityGate(getSchemaVersion(db)) + if (isChatSessionDb(db)) { + const schemaVersion = getSchemaVersion(db) + // SQLite INTEGER-affinity columns may actually contain TEXT, REAL or + // BLOB values. A type assertion cannot protect numeric comparisons + // from coercing invalid values (e.g. nonnumeric text to NaN). + if (!Number.isSafeInteger(schemaVersion) || schemaVersion < 0) { + throw new Error( + `ChatLab session ${sessionId} has an invalid schema version. ` + + 'Cannot safely serve this database through read-only MCP tools.' + ) + } + // The runtime cannot understand a future schema or safely repair its + // compatibility metadata; report the version mismatch before the gate. + if (schemaVersion > CURRENT_SCHEMA_VERSION) { + throw new Error( + `ChatLab session ${sessionId} uses schema v${schemaVersion}, but read-only MCP tools require ` + + `schema v${CURRENT_SCHEMA_VERSION}. Upgrade the ChatLab runtime to one that supports this newer schema.` + ) + } + + this.assertReadonlyCompatibilityGate(schemaVersion) + if (schemaVersion < CURRENT_SCHEMA_VERSION) { + throw new Error( + `ChatLab session ${sessionId} uses schema v${schemaVersion}, but read-only MCP tools require ` + + `schema v${CURRENT_SCHEMA_VERSION}. Open the session with the current writable ChatLab runtime to migrate it first.` + ) + } + } return db } catch (error) { db.close() @@ -228,6 +265,7 @@ export class DatabaseManager { /** * 只读列举聊天会话 ID,不触发数据库迁移或兼容门禁提升。 + * CLI 导入预分析需要枚举未升级的旧库,因此不可强制当前 Schema。 */ listSessionIdsReadonly(): string[] { this.assertCompatible() @@ -244,6 +282,21 @@ export class DatabaseManager { }) } + /** MCP session discovery performs the same strict schema preflight as MCP queries. */ + listSessionIdsMcpReadonly(): string[] { + this.assertCompatible() + + return listDatabaseCandidateIds(this.pathProvider.getDatabaseDir()).filter((sessionId) => { + const db = this.openMcpReadonly(sessionId) + if (!db) return false + try { + return isChatSessionDb(db) + } finally { + db.close() + } + }) + } + getUserDataDir(): string { return this.pathProvider.getUserDataDir() } diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index f63117bf..a83e8040 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -41,8 +41,8 @@ test('MCP tools/list array schemas agree with tools/call validation and database await startMcpServer({ version: '1.0.0', dbManager: { - listSessionIdsReadonly: () => ['fixture'], - openReadonly: (id) => (id === 'fixture' ? readonlyFixture : null), + listSessionIdsMcpReadonly: () => ['fixture'], + openMcpReadonly: (id) => (id === 'fixture' ? readonlyFixture : null), }, }) await client.connect(clientTransport)