fix(mcp): validate session schema before read-only queries

This commit is contained in:
moesnow authored and digua committed 2026-10-24 16:26:22 +08:00
1 parent 69116a0321
commit d42e1d1bfb
7 files changed
+448 -24

No files matched your search

@@ -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()
}
})
+9 -7
View File
@@ -23,7 +23,7 @@ test('advertises every ChatLab MCP query tool as read-only', async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
try { try {
registerTools(server, { listSessionIdsReadonly: () => [], openReadonly: () => null }) registerTools(server, { listSessionIdsMcpReadonly: () => [], openMcpReadonly: () => null })
await server.connect(serverTransport) await server.connect(serverTransport)
await client.connect(clientTransport) 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-')) const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-readonly-'))
t.after(() => fs.rmSync(root, { recursive: true, force: true })) 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' }], ['chatlab_execute_sql', { session_id: 'legacy', sql: 'SELECT content FROM message' }],
] as const) { ] as const) {
const result = await client.callTool({ name, arguments: { ...args, format: 'json' } }) 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.equal(readDataDirCompatibilityMeta(dataDir), null, `${name} raised the compatibility gate`)
assert.deepEqual(fs.readFileSync(dbPath), initialDatabase, `${name} modified the legacy database`) 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-')) const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-v9-summaries-'))
t.after(() => fs.rmSync(root, { recursive: true, force: true })) t.after(() => fs.rmSync(root, { recursive: true, force: true }))
const dataDir = path.join(root, 'data') 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', name: 'chatlab_get_segment_summaries',
arguments: { session_id: 'v9', format: 'json' }, arguments: { session_id: 'v9', format: 'json' },
}) })
assert.equal(oldResult.isError, undefined, JSON.stringify(oldResult.content)) assert.equal(oldResult.isError, true, JSON.stringify(oldResult.content))
assert.match((oldResult.content as Array<{ text: string }>)[0].text, /No segments with summaries found/) assert.match(JSON.stringify(oldResult.content), /schema v9.*schema v10.*migrate/i)
assert.deepEqual(fs.readFileSync(legacyPath), beforeV9) assert.deepEqual(fs.readFileSync(legacyPath), beforeV9)
assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), beforeGate) 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', name: 'chatlab_get_session_info',
arguments: { session_id: 'v6', format: 'json' }, 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(dbPath), beforeDb)
assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), gateAfterRepair) assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), gateAfterRepair)
} finally { } finally {
+7 -7
View File
@@ -60,9 +60,9 @@ export function registerTools(server: McpServer, dbManager: McpDatabaseManager,
const context: SessionListContext = { const context: SessionListContext = {
db: null as any, db: null as any,
sessionId: '', sessionId: '',
listSessionIds: () => dbManager.listSessionIdsReadonly(), listSessionIds: () => dbManager.listSessionIdsMcpReadonly(),
openDb: (id) => { openDb: (id) => {
const db = dbManager.openReadonly(id) const db = dbManager.openMcpReadonly(id)
if (db) openedDbs.push(db) if (db) openedDbs.push(db)
return db return db
}, },
@@ -92,7 +92,7 @@ export function registerTools(server: McpServer, dbManager: McpDatabaseManager,
async (params) => { async (params) => {
const sessionId = params.session_id as string const sessionId = params.session_id as string
const format = params.format as string | undefined const format = params.format as string | undefined
const db = dbManager.openReadonly(sessionId) const db = dbManager.openMcpReadonly(sessionId)
if (!db) { if (!db) {
return { return {
content: [{ type: 'text' as const, text: `Session not found: ${sessionId}` }], 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 { function registerResources(server: McpServer, dbManager: McpDatabaseManager): void {
server.resource('sessions-list', 'chatlab://sessions', { description: '所有已导入的聊天会话列表' }, async () => { server.resource('sessions-list', 'chatlab://sessions', { description: '所有已导入的聊天会话列表' }, async () => {
const sessionIds = dbManager.listSessionIdsReadonly() const sessionIds = dbManager.listSessionIdsMcpReadonly()
const sessions = sessionIds const sessions = sessionIds
.map((id) => { .map((id) => {
const db = dbManager.openReadonly(id) const db = dbManager.openMcpReadonly(id)
if (!db) return null if (!db) return null
try { try {
const meta = getSessionMeta(db) const meta = getSessionMeta(db)
@@ -150,7 +150,7 @@ function registerResources(server: McpServer, dbManager: McpDatabaseManager): vo
{ description: '会话元信息(名称、平台、消息数等)' }, { description: '会话元信息(名称、平台、消息数等)' },
async (uri, params) => { async (uri, params) => {
const sessionId = params.sessionId as string const sessionId = params.sessionId as string
const db = dbManager.openReadonly(sessionId) const db = dbManager.openMcpReadonly(sessionId)
if (!db) { if (!db) {
return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] } 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: '会话数据库的表结构' }, { description: '会话数据库的表结构' },
async (uri, params) => { async (uri, params) => {
const sessionId = params.sessionId as string const sessionId = params.sessionId as string
const db = dbManager.openReadonly(sessionId) const db = dbManager.openMcpReadonly(sessionId)
if (!db) { if (!db) {
return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] } return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] }
} }
+4 -4
View File
@@ -9,10 +9,10 @@ import type { DatabaseAdapter } from '@openchatlab/core'
* Both CLI's DatabaseManager and Desktop's helper can satisfy this contract. * Both CLI's DatabaseManager and Desktop's helper can satisfy this contract.
*/ */
export interface McpDatabaseManager { export interface McpDatabaseManager {
/** List chat sessions without running schema migrations. */ /** List only MCP-supported chat sessions; reject any requiring an upgrade. */
listSessionIdsReadonly(): string[] listSessionIdsMcpReadonly(): string[]
/** Open a non-cached, strictly read-only connection; caller must close it. */ /** Open a non-cached, strictly read-only MCP connection after schema preflight; caller closes it. */
openReadonly(sessionId: string): DatabaseAdapter | null openMcpReadonly(sessionId: string): DatabaseAdapter | null
} }
export interface McpServerOptions { export interface McpServerOptions {
@@ -1328,3 +1328,152 @@ test('session deletion rejects a live topic execution owned by another runtime',
reopened.close() 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 })
}
}
})
+57 -4
View File
@@ -133,16 +133,53 @@ export class DatabaseManager {
} }
/** /**
* Open an uncached session database strictly for reading, without migrations or * Open an uncached session database strictly for reading, without migrations.
* compatibility gate updates. The caller owns and must close the connection. * 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 { openReadonly(sessionId: string): DatabaseAdapter | null {
this.assertCompatible() this.assertCompatible()
if (!fs.existsSync(this.getDbPath(sessionId))) return null 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 { 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 return db
} catch (error) { } catch (error) {
db.close() db.close()
@@ -228,6 +265,7 @@ export class DatabaseManager {
/** /**
* 只读列举聊天会话 ID,不触发数据库迁移或兼容门禁提升。 * 只读列举聊天会话 ID,不触发数据库迁移或兼容门禁提升。
* CLI 导入预分析需要枚举未升级的旧库,因此不可强制当前 Schema。
*/ */
listSessionIdsReadonly(): string[] { listSessionIdsReadonly(): string[] {
this.assertCompatible() 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 { getUserDataDir(): string {
return this.pathProvider.getUserDataDir() return this.pathProvider.getUserDataDir()
} }
+2 -2
View File
@@ -41,8 +41,8 @@ test('MCP tools/list array schemas agree with tools/call validation and database
await startMcpServer({ await startMcpServer({
version: '1.0.0', version: '1.0.0',
dbManager: { dbManager: {
listSessionIdsReadonly: () => ['fixture'], listSessionIdsMcpReadonly: () => ['fixture'],
openReadonly: (id) => (id === 'fixture' ? readonlyFixture : null), openMcpReadonly: (id) => (id === 'fixture' ? readonlyFixture : null),
}, },
}) })
await client.connect(clientTransport) await client.connect(clientTransport)