mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(mcp): reject reads with unrepaired compatibility gate
This commit is contained in:
1 parent
bedc8f1d71
commit
69116a0321
3 files changed
+164
-9
No files matched your search
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user