fix(mcp): reject reads with unrepaired compatibility gate

This commit is contained in:
moesnow authored and digua committed 2026-10-24 16:26:22 +08:00
1 parent bedc8f1d71
commit 69116a0321
3 files changed
+164 -9

No files matched your search

+110 -5
View File
@@ -9,7 +9,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import Database from 'better-sqlite3'
import { CHAT_DB_TABLES } from '@openchatlab/core'
import { MCP_TOOL_REGISTRY } from '@openchatlab/tools'
import { DatabaseManager, NodePathProvider, readDataDirCompatibilityMeta } from '@openchatlab/node-runtime'
import {
DatabaseManager,
NodePathProvider,
raiseDataDirMinRuntimeVersion,
readDataDirCompatibilityMeta,
} from '@openchatlab/node-runtime'
import { registerTools } from './server'
test('advertises every ChatLab MCP query tool as read-only', async () => {
@@ -180,11 +185,21 @@ test('segment summaries handle v9 pending schema without migration and preserve
currentDb.close()
}
const pathProvider = new NodePathProvider(dataDir)
// Schema v9 has crossed the compatibility-gate boundary at v6. A writable
// runtime must have repaired this metadata before read-only MCP access.
raiseDataDirMinRuntimeVersion(pathProvider, {
minRuntimeVersion: '0.25.1',
dataCompatibilityVersion: 1,
reason: 'segment-schema',
runtime: { version: '0.37.6', kind: 'desktop' },
module: 'test-fixture',
})
const beforeV9 = fs.readFileSync(legacyPath)
const beforeV10 = fs.readFileSync(currentPath)
assert.equal(readDataDirCompatibilityMeta(dataDir), null)
const beforeGate = fs.readFileSync(path.join(dataDir, '.chatlab-meta.json'))
const dbManager = new DatabaseManager(new NodePathProvider(dataDir), {
const dbManager = new DatabaseManager(pathProvider, {
runtime: { version: '0.37.6', kind: 'mcp' },
})
t.after(() => dbManager.closeAll())
@@ -204,7 +219,7 @@ test('segment summaries handle v9 pending schema without migration and preserve
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.deepEqual(fs.readFileSync(legacyPath), beforeV9)
assert.equal(readDataDirCompatibilityMeta(dataDir), null)
assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), beforeGate)
const currentResult = await client.callTool({
name: 'chatlab_get_segment_summaries',
@@ -216,7 +231,7 @@ test('segment summaries handle v9 pending schema without migration and preserve
assert.equal(parsed.segments[0].summary, 'Verified summary')
assert.deepEqual(parsed.segments[0].participants, ['Bob'])
assert.deepEqual(fs.readFileSync(currentPath), beforeV10)
assert.equal(readDataDirCompatibilityMeta(dataDir), null)
assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), beforeGate)
} finally {
await client.close()
await server.close()
@@ -236,3 +251,93 @@ test('segment summaries handle v9 pending schema without migration and preserve
verifyLegacy.close()
}
})
test('read-only MCP rejects schema v6 without a repaired compatibility gate', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-missing-gate-'))
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, 'v6.db')
const fixture = new Database(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 6
);
INSERT INTO meta (name, platform, type, imported_at, schema_version)
VALUES ('v6 chat', 'qq', 'group', 1000, 6);
CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT UNIQUE, account_name TEXT);
CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT);
`)
} finally {
fixture.close()
}
const beforeDb = fs.readFileSync(dbPath)
assert.equal(readDataDirCompatibilityMeta(dataDir), null)
const pathProvider = new NodePathProvider(dataDir)
const manager = new DatabaseManager(pathProvider, { runtime: { version: '0.37.6', kind: 'mcp' } })
t.after(() => manager.closeAll())
const server = new McpServer({ name: 'chatlab-test', version: '0.37.6' })
const client = new Client({ name: 'chatlab-test-client', version: '0.0.0' })
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
try {
registerTools(server, manager)
await server.connect(serverTransport)
await client.connect(clientTransport)
for (const [name, args] of [
['chatlab_list_sessions', {}],
['chatlab_get_session_info', { session_id: 'v6' }],
['chatlab_get_schema', { session_id: 'v6' }],
['chatlab_execute_sql', { session_id: 'v6', sql: 'SELECT 1' }],
] as const) {
const result = await client.callTool({ name, arguments: { ...args, format: 'json' } })
assert.equal(result.isError, true, `${name} should reject unrepaired metadata`)
assert.match(JSON.stringify(result.content), /current writable ChatLab runtime/i)
assert.equal(readDataDirCompatibilityMeta(dataDir), null)
assert.deepEqual(fs.readFileSync(dbPath), beforeDb, `${name} changed the database`)
}
// Merely having metadata is insufficient: the marker must include the
// migration's minimum version, compatibility level and reason.
raiseDataDirMinRuntimeVersion(pathProvider, {
minRuntimeVersion: '0.24.0',
dataCompatibilityVersion: 0,
reason: 'unrelated',
runtime: { version: '0.37.6', kind: 'desktop' },
module: 'test-fixture',
})
const inadequateGate = fs.readFileSync(path.join(dataDir, '.chatlab-meta.json'))
const inadequateResult = await client.callTool({
name: 'chatlab_get_schema',
arguments: { session_id: 'v6', format: 'json' },
})
assert.equal(inadequateResult.isError, true)
assert.match(JSON.stringify(inadequateResult.content), /current writable ChatLab runtime/i)
assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), inadequateGate)
assert.deepEqual(fs.readFileSync(dbPath), beforeDb)
// The writable runtime is responsible for repairing this marker separately.
raiseDataDirMinRuntimeVersion(pathProvider, {
minRuntimeVersion: '0.25.1',
dataCompatibilityVersion: 1,
reason: 'segment-schema',
runtime: { version: '0.37.6', kind: 'desktop' },
module: 'test-fixture',
})
const gateAfterRepair = fs.readFileSync(path.join(dataDir, '.chatlab-meta.json'))
const result = await client.callTool({
name: 'chatlab_get_session_info',
arguments: { session_id: 'v6', format: 'json' },
})
assert.equal(result.isError, undefined, JSON.stringify(result.content))
assert.deepEqual(fs.readFileSync(dbPath), beforeDb)
assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), gateAfterRepair)
} finally {
await client.close()
await server.close()
}
})
+4 -1
View File
@@ -36,7 +36,10 @@ export interface AssertDataDirCompatibilityOptions {
env?: Pick<NodeJS.ProcessEnv, 'CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR'>
}
type DataDirCompatibilityErrorCode = 'DATA_DIR_REQUIRES_NEWER_RUNTIME' | 'DATA_DIR_COMPATIBILITY_META_INVALID'
type DataDirCompatibilityErrorCode =
| 'DATA_DIR_REQUIRES_NEWER_RUNTIME'
| 'DATA_DIR_COMPATIBILITY_META_INVALID'
| 'DATA_DIR_COMPATIBILITY_REPAIR_REQUIRED'
export class DataDirCompatibilityError extends Error {
readonly code: DataDirCompatibilityErrorCode
+50 -3
View File
@@ -19,7 +19,13 @@ import {
} from '@openchatlab/core'
import { openBetterSqliteDatabase } from './better-sqlite3-adapter'
import { deleteSessionCache } from './cache/session-cache'
import { assertDataDirCompatible, type RuntimeIdentity } from './data-dir-compat'
import {
assertDataDirCompatible,
DataDirCompatibilityError,
isRuntimeVersionAtLeast,
readDataDirCompatibilityMeta,
type RuntimeIdentity,
} from './data-dir-compat'
import { CHAT_DB_COMPATIBILITY_RAISES, getChatDbMigrations, raiseChatDbCompatibilityGate } from './migrations'
import { getContactsFactsCacheDir } from './services/contacts/paths'
import {
@@ -133,7 +139,15 @@ export class DatabaseManager {
openReadonly(sessionId: string): DatabaseAdapter | null {
this.assertCompatible()
if (!fs.existsSync(this.getDbPath(sessionId))) return null
return this.openRawSessionDatabase(sessionId, { readonly: true })
const db = this.openRawSessionDatabase(sessionId, { readonly: true })
try {
if (isChatSessionDb(db)) this.assertReadonlyCompatibilityGate(getSchemaVersion(db))
return db
} catch (error) {
db.close()
throw error
}
}
/**
@@ -220,7 +234,8 @@ export class DatabaseManager {
const dbDir = this.pathProvider.getDatabaseDir()
return listDatabaseCandidateIds(dbDir).filter((sessionId) => {
const db = this.openRawSessionDatabase(sessionId, { readonly: true })
const db = this.openReadonly(sessionId)
if (!db) return false
try {
return isChatSessionDb(db)
} finally {
@@ -306,6 +321,38 @@ export class DatabaseManager {
raiseChatDbCompatibilityGate(this.pathProvider, this.runtime)
}
/**
* A read-only MCP request cannot repair missing compatibility metadata. Reject
* affected schemas until a writable runtime has safely restored the gate.
*/
private assertReadonlyCompatibilityGate(schemaVersion: number): void {
if (!this.runtime || !shouldRepairCompatibilityGate(schemaVersion)) return
const userDataDir = this.pathProvider.getUserDataDir()
const meta = readDataDirCompatibilityMeta(userDataDir)
const missingGate = CHAT_DB_COMPATIBILITY_RAISES.find(
({ migrationVersion, minRuntimeVersion, dataCompatibilityVersion, reason }) =>
schemaVersion >= migrationVersion &&
(!meta ||
!isRuntimeVersionAtLeast(meta.minRuntimeVersion, minRuntimeVersion) ||
meta.dataCompatibilityVersion < dataCompatibilityVersion ||
!meta.reasons.includes(reason))
)
if (!missingGate) return
throw new DataDirCompatibilityError(
'DATA_DIR_COMPATIBILITY_REPAIR_REQUIRED',
`ChatLab session schema v${schemaVersion} requires an updated data-directory compatibility marker. ` +
'Open the data directory with the current writable ChatLab runtime to repair it before using read-only MCP tools.',
{
userDataDir,
metaPath: path.join(userDataDir, '.chatlab-meta.json'),
currentVersion: this.runtime.version,
minRuntimeVersion: missingGate.minRuntimeVersion,
}
)
}
private assertCompatible(): void {
if (!this.runtime) return
assertDataDirCompatible(this.pathProvider, this.runtime)