mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
Compare commits
6
Commits
fa9a62f772
...
1a214a5a9e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a214a5a9e | ||
|
|
d42e1d1bfb | ||
|
|
69116a0321 | ||
|
|
bedc8f1d71 | ||
|
|
eda2694261 | ||
|
|
395f124299 |
No files matched your search
@@ -317,7 +317,10 @@ export function getSegmentSummaries(
|
||||
db: DatabaseAdapter,
|
||||
options?: { limit?: number; timeFilter?: TimeFilter }
|
||||
): SegmentSummaryData[] {
|
||||
if (!hasTable(db, 'segment')) return []
|
||||
// Schema v10 introduced summary_message_count. Earlier summaries have unknown
|
||||
// coverage and are treated as stale by the v10 migration; do the same for
|
||||
// read-only callers without mutating older databases.
|
||||
if (!hasTable(db, 'segment') || !hasColumn(db, 'segment', 'summary_message_count')) return []
|
||||
|
||||
const { limit = 50, timeFilter } = options ?? {}
|
||||
|
||||
|
||||
@@ -51,7 +51,9 @@ If you install the package globally, you can also use:
|
||||
|
||||
If you configured a custom ChatLab data directory in `~/.chatlab/config.toml`, the MCP server will use that configuration automatically.
|
||||
|
||||
The server is read-only. It opens existing ChatLab session databases and does not modify chat data.
|
||||
MCP tools use native read-only SQLite connections and short read transactions. They do not modify logical chat records, database schema, or the data-directory compatibility marker, and they never automatically migrate sessions. A session with an older schema must first be upgraded explicitly by a writable ChatLab runtime.
|
||||
|
||||
**Live SQLite caveat:** WAL-mode reads may create/update `-wal` or `-shm` coordination files beside the database. This mode does **not** guarantee an unchanged filesystem directory, nor universal support for read-only mounts or databases requiring recovery. MCP query tools declare `readOnlyHint: true` and `destructiveHint: false` because they do not intentionally change _logical chat data_, perform migrations, or update compatibility metadata. Whether SQLite's auxiliary WAL/SHM coordination is compatible with the MCP annotation's broader "does not modify its environment" wording is a product/contract question for the upstream maintainers; the annotation is not a filesystem-sandbox guarantee. Queries do not create full temporary copies of chat databases.
|
||||
|
||||
## Available Capabilities
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
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 { CHAT_DB_TABLES, 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()
|
||||
}
|
||||
})
|
||||
|
||||
test('real MCP tools and resources read checkpointed WAL without logical data/marker mutation', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-wal-live-'))
|
||||
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, 'wal.db')
|
||||
const fixture = openTestSqliteDatabase(dbPath)
|
||||
try {
|
||||
fixture.pragma('journal_mode = WAL')
|
||||
fixture.exec(CHAT_DB_TABLES)
|
||||
fixture.exec(`
|
||||
INSERT INTO meta (name, platform, type, imported_at) VALUES ('WAL live', 'qq', 'group', 1000);
|
||||
INSERT INTO member (platform_id, account_name) VALUES ('alice', 'Alice');
|
||||
INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'WAL query works');
|
||||
`)
|
||||
} finally {
|
||||
fixture.close()
|
||||
}
|
||||
const dbManager = new DatabaseManager(new NodePathProvider(dataDir), {
|
||||
runtime: { version: '0.37.6', kind: 'mcp' },
|
||||
})
|
||||
t.after(() => dbManager.closeAll())
|
||||
dbManager.raiseCurrentChatDbCompatibilityGate()
|
||||
const original = fs.readFileSync(dbPath)
|
||||
const metaPath = path.join(dataDir, '.chatlab-meta.json')
|
||||
const marker = fs.readFileSync(metaPath)
|
||||
|
||||
const client = new Client({ name: 'wal-live-test', 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_get_session_info', { session_id: 'wal' }],
|
||||
['chatlab_get_recent_messages', { session_id: 'wal' }],
|
||||
['chatlab_execute_sql', { session_id: 'wal', 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)}`)
|
||||
}
|
||||
for (const uri of ['chatlab://sessions', 'chatlab://sessions/wal/meta', 'chatlab://sessions/wal/schema']) {
|
||||
const resource = await client.readResource({ uri })
|
||||
assert.ok(resource.contents.length > 0, uri)
|
||||
}
|
||||
// Native readonly WAL readers may create coordination sidecars, but may
|
||||
// not modify logical chat records, DB schema or compatibility metadata.
|
||||
assert.deepEqual(fs.readFileSync(dbPath), original)
|
||||
assert.deepEqual(fs.readFileSync(metaPath), marker)
|
||||
const readonlyDb = openTestSqliteDatabase(dbPath, { readonly: true })
|
||||
try {
|
||||
assert.deepEqual(readonlyDb.prepare('SELECT content FROM message').all(), [{ content: 'WAL query works' }])
|
||||
} finally {
|
||||
readonlyDb.close()
|
||||
}
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
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 Database from 'better-sqlite3'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { CHAT_DB_TABLES } from '@openchatlab/core'
|
||||
import { DatabaseManager, NodePathProvider } from '@openchatlab/node-runtime'
|
||||
import { startMcpServer } from './server'
|
||||
|
||||
/** Full MCP protocol tests for the two trust boundaries identified in PR review. */
|
||||
test('MCP confines session IDs to real ChatLab databases and rejects SQL prepare side effects', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-mcp-safety-'))
|
||||
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, 'valid.db')
|
||||
const db = new Database(dbPath)
|
||||
try {
|
||||
db.pragma('journal_mode=WAL')
|
||||
db.exec(CHAT_DB_TABLES)
|
||||
db.exec(`
|
||||
INSERT INTO meta(name, platform, type, imported_at)
|
||||
VALUES('Valid chat', 'qq', 'group', 1000);
|
||||
INSERT INTO member(platform_id, account_name) VALUES('p1', 'Alice');
|
||||
INSERT INTO message(sender_id, ts, type, content) VALUES(1, 1000, 0, 'allowed chat content');
|
||||
`)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
|
||||
const privateDbPath = path.join(dataDir, 'private.db')
|
||||
const privateDb = new Database(privateDbPath)
|
||||
try {
|
||||
privateDb.exec("CREATE TABLE secrets(value TEXT); INSERT INTO secrets VALUES('SYNTHETIC_OUTSIDE_SESSION_SECRET');")
|
||||
} finally {
|
||||
privateDb.close()
|
||||
}
|
||||
const foreignDbPath = path.join(dbDir, 'foreign.db')
|
||||
const foreignDb = new Database(foreignDbPath)
|
||||
try {
|
||||
foreignDb.exec("CREATE TABLE secrets(value TEXT); INSERT INTO secrets VALUES('NON_CHATLAB_SECRET');")
|
||||
} finally {
|
||||
foreignDb.close()
|
||||
}
|
||||
|
||||
const manager = new DatabaseManager(new NodePathProvider(dataDir), {
|
||||
runtime: { version: '0.37.6', kind: 'mcp' },
|
||||
})
|
||||
manager.raiseCurrentChatDbCompatibilityGate()
|
||||
t.after(() => manager.closeAll())
|
||||
assert.deepEqual(manager.listSessionIdsMcpReadonly(), ['valid'])
|
||||
// File symlinks require elevated privilege on some Windows test runners.
|
||||
// Exercise the symlink boundary on POSIX; Windows-specific path checks still run.
|
||||
if (process.platform !== 'win32') fs.symlinkSync(privateDbPath, path.join(dbDir, 'linked.db'))
|
||||
const markerPath = path.join(dataDir, '.chatlab-meta.json')
|
||||
const beforeMarker = fs.readFileSync(markerPath)
|
||||
const beforeDb = fs.readFileSync(dbPath)
|
||||
|
||||
const client = new Client({ name: 'mcp-safety-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: manager })
|
||||
await client.connect(clientTransport)
|
||||
try {
|
||||
for (const sessionId of [
|
||||
'../private',
|
||||
'..\\private',
|
||||
'C:\\outside',
|
||||
'/tmp/outside',
|
||||
'name:stream',
|
||||
'bad\u0000id',
|
||||
'.',
|
||||
'..',
|
||||
'linked',
|
||||
'foreign',
|
||||
]) {
|
||||
const result = await client.callTool({
|
||||
name: 'chatlab_execute_sql',
|
||||
arguments: { session_id: sessionId, sql: 'SELECT * FROM secrets', format: 'json' },
|
||||
})
|
||||
assert.equal(result.isError, true, `${JSON.stringify(sessionId)} escaped session boundary`)
|
||||
assert.doesNotMatch(JSON.stringify(result.content), /SYNTHETIC_OUTSIDE_SESSION_SECRET|NON_CHATLAB_SECRET/)
|
||||
}
|
||||
for (const uri of [
|
||||
'chatlab://sessions/..%2Fprivate/meta',
|
||||
'chatlab://sessions/..%5Cprivate/schema',
|
||||
'chatlab://sessions/linked/meta',
|
||||
'chatlab://sessions/foreign/schema',
|
||||
]) {
|
||||
let body: string
|
||||
try {
|
||||
const result = await client.readResource({ uri })
|
||||
body = JSON.stringify(result.contents)
|
||||
} catch (error) {
|
||||
body = String(error)
|
||||
}
|
||||
assert.doesNotMatch(body, /SYNTHETIC_OUTSIDE_SESSION_SECRET|NON_CHATLAB_SECRET/, uri)
|
||||
assert.match(body, /Session not found|Invalid MCP session|Not a ChatLab|not found|Invalid Request/i, uri)
|
||||
}
|
||||
|
||||
const check = async (sql: string) =>
|
||||
client.callTool({ name: 'chatlab_execute_sql', arguments: { session_id: 'valid', sql, format: 'json' } })
|
||||
// Query a second SQLite connection to prove that prepare() itself did not
|
||||
// change the process-global temp_store_directory setting.
|
||||
const probe = new Database(':memory:')
|
||||
const beforeTempDirectory = probe.pragma('temp_store_directory')
|
||||
try {
|
||||
const pragma = await check(`PRAGMA temp_store_directory='${root.replace(/'/g, "''")}' -- LIMIT`)
|
||||
assert.equal(pragma.isError, true)
|
||||
assert.match(JSON.stringify(pragma.content), /Only SELECT or WITH/i)
|
||||
assert.deepEqual(probe.pragma('temp_store_directory'), beforeTempDirectory)
|
||||
} finally {
|
||||
probe.close()
|
||||
}
|
||||
for (const sql of [
|
||||
'ATTACH DATABASE ":memory:" AS extra',
|
||||
'EXPLAIN PRAGMA temp_store_directory',
|
||||
'BEGIN',
|
||||
'/* comment */ PRAGMA writable_schema=ON',
|
||||
]) {
|
||||
const result = await check(sql)
|
||||
assert.equal(result.isError, true, sql)
|
||||
assert.match(JSON.stringify(result.content), /Only SELECT or WITH/i, sql)
|
||||
}
|
||||
// WITH is allowed lexically, but SQLite's native readonly flag must
|
||||
// still reject DML hidden behind the CTE entry point.
|
||||
const withWrite = await check(
|
||||
"WITH c(x) AS (SELECT 1) UPDATE message SET content='TAMPERED' WHERE id IN (SELECT x FROM c)"
|
||||
)
|
||||
assert.match(JSON.stringify(withWrite.content), /read-only statements/i)
|
||||
|
||||
const validSql = [
|
||||
'SELECT content FROM message',
|
||||
'-- comment\n SELECT content FROM message',
|
||||
'/* leading */\nWITH c(x) AS (SELECT 7) SELECT x FROM c',
|
||||
]
|
||||
for (const sql of validSql) {
|
||||
const result = await check(sql)
|
||||
assert.equal(result.isError, undefined, `${sql}: ${JSON.stringify(result.content)}`)
|
||||
}
|
||||
assert.deepEqual(fs.readFileSync(dbPath), beforeDb)
|
||||
assert.deepEqual(fs.readFileSync(markerPath), beforeMarker)
|
||||
const other = new Database(privateDbPath, { readonly: true })
|
||||
try {
|
||||
assert.deepEqual(other.prepare('SELECT value FROM secrets').all(), [
|
||||
{ value: 'SYNTHETIC_OUTSIDE_SESSION_SECRET' },
|
||||
])
|
||||
} finally {
|
||||
other.close()
|
||||
}
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,375 @@
|
||||
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 Database from 'better-sqlite3'
|
||||
import { CHAT_DB_TABLES } from '@openchatlab/core'
|
||||
import { MCP_TOOL_REGISTRY } from '@openchatlab/tools'
|
||||
import {
|
||||
DatabaseManager,
|
||||
NodePathProvider,
|
||||
raiseDataDirMinRuntimeVersion,
|
||||
readDataDirCompatibilityMeta,
|
||||
} from '@openchatlab/node-runtime'
|
||||
import { registerTools } from './server'
|
||||
|
||||
test('advertises logical ChatLab MCP queries as read-only', async () => {
|
||||
const server = new McpServer({ name: 'chatlab-test', version: '0.0.0' })
|
||||
const client = new Client({ name: 'chatlab-test-client', version: '0.0.0' })
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
|
||||
try {
|
||||
registerTools(server, { listSessionIdsMcpReadonly: () => [], openMcpReadonly: () => null })
|
||||
await server.connect(serverTransport)
|
||||
await client.connect(clientTransport)
|
||||
|
||||
const { tools } = await client.listTools()
|
||||
assert.deepEqual(
|
||||
tools.map((tool) => tool.name).sort(),
|
||||
MCP_TOOL_REGISTRY.map((tool) => `chatlab_${tool.name}`).sort()
|
||||
)
|
||||
for (const tool of tools) {
|
||||
assert.deepEqual(
|
||||
tool.annotations,
|
||||
{
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
tool.name
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await client.close()
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects unsafe raw MCP SQL before opening a database', async () => {
|
||||
const server = new McpServer({ name: 'chatlab-sql-precheck', version: '0.0.0' })
|
||||
const client = new Client({ name: 'chatlab-sql-precheck-client', version: '0.0.0' })
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
let databaseOpenCalls = 0
|
||||
|
||||
try {
|
||||
registerTools(server, {
|
||||
listSessionIdsMcpReadonly: () => [],
|
||||
openMcpReadonly: () => {
|
||||
databaseOpenCalls++
|
||||
throw new Error('Unsafe SQL should never open a database')
|
||||
},
|
||||
})
|
||||
await server.connect(serverTransport)
|
||||
await client.connect(clientTransport)
|
||||
|
||||
const result = await client.callTool({
|
||||
name: 'chatlab_execute_sql',
|
||||
arguments: { session_id: 'missing', sql: "PRAGMA temp_store_directory='/tmp'" },
|
||||
})
|
||||
assert.equal(result.isError, true)
|
||||
assert.match(JSON.stringify(result.content), /Only SELECT or WITH queries/i)
|
||||
assert.equal(databaseOpenCalls, 0)
|
||||
} finally {
|
||||
await client.close()
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
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 }))
|
||||
|
||||
const dataDir = path.join(root, 'data')
|
||||
const dbDir = path.join(dataDir, 'databases')
|
||||
fs.mkdirSync(dbDir, { recursive: true })
|
||||
const dbPath = path.join(dbDir, 'legacy.db')
|
||||
|
||||
// Version 5 predates the v6 migration that can raise the compatibility gate.
|
||||
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 5
|
||||
);
|
||||
INSERT INTO meta (name, platform, type, imported_at, schema_version)
|
||||
VALUES ('Legacy chat', 'qq', 'group', 1000, 5);
|
||||
|
||||
CREATE TABLE member (
|
||||
id INTEGER PRIMARY KEY,
|
||||
platform_id TEXT NOT NULL UNIQUE,
|
||||
account_name TEXT
|
||||
);
|
||||
INSERT INTO member (id, platform_id, account_name) VALUES (1, 'legacy-user', 'Alice');
|
||||
|
||||
CREATE TABLE message (
|
||||
id INTEGER PRIMARY KEY,
|
||||
sender_id INTEGER NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
content TEXT
|
||||
);
|
||||
INSERT INTO message (id, sender_id, ts, type, content)
|
||||
VALUES (1, 1, 1000, 0, 'hello from legacy schema');
|
||||
`)
|
||||
} finally {
|
||||
fixture.close()
|
||||
}
|
||||
const initialDatabase = fs.readFileSync(dbPath)
|
||||
assert.equal(readDataDirCompatibilityMeta(dataDir), null)
|
||||
|
||||
const dbManager = new DatabaseManager(new NodePathProvider(dataDir), {
|
||||
runtime: { version: '0.37.6', kind: 'mcp' },
|
||||
})
|
||||
t.after(() => dbManager.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, dbManager)
|
||||
await server.connect(serverTransport)
|
||||
await client.connect(clientTransport)
|
||||
|
||||
for (const [name, args] of [
|
||||
['chatlab_list_sessions', {}],
|
||||
['chatlab_get_schema', { session_id: 'legacy' }],
|
||||
['chatlab_get_session_info', { session_id: 'legacy' }],
|
||||
['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, 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`)
|
||||
}
|
||||
} finally {
|
||||
await client.close()
|
||||
await server.close()
|
||||
}
|
||||
|
||||
const after = new Database(dbPath, { readonly: true })
|
||||
try {
|
||||
const version = after.prepare('SELECT schema_version FROM meta').get() as { schema_version: number }
|
||||
assert.equal(version.schema_version, 5)
|
||||
assert.equal(after.prepare("SELECT name FROM sqlite_master WHERE name = 'segment'").get(), undefined)
|
||||
} finally {
|
||||
after.close()
|
||||
}
|
||||
})
|
||||
|
||||
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')
|
||||
const dbDir = path.join(dataDir, 'databases')
|
||||
fs.mkdirSync(dbDir, { recursive: true })
|
||||
|
||||
const legacyPath = path.join(dbDir, 'v9.db')
|
||||
const legacyDb = new Database(legacyPath)
|
||||
try {
|
||||
legacyDb.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 9
|
||||
);
|
||||
INSERT INTO meta (name, platform, type, imported_at, schema_version)
|
||||
VALUES ('v9 chat', 'qq', 'group', 1000, 9);
|
||||
CREATE TABLE member (id INTEGER PRIMARY KEY, platform_id TEXT UNIQUE, account_name TEXT);
|
||||
INSERT INTO member VALUES (1, 'legacy-user', 'Alice');
|
||||
CREATE TABLE message (id INTEGER PRIMARY KEY, sender_id INTEGER, ts INTEGER, type INTEGER, content TEXT);
|
||||
INSERT INTO message VALUES (1, 1, 1000, 0, 'hello');
|
||||
CREATE TABLE segment (
|
||||
id INTEGER PRIMARY KEY, start_ts INTEGER, end_ts INTEGER,
|
||||
message_count INTEGER, summary TEXT
|
||||
);
|
||||
INSERT INTO segment VALUES (1, 1000, 1000, 1, 'Old unverified summary');
|
||||
CREATE TABLE message_context (message_id INTEGER PRIMARY KEY, segment_id INTEGER, topic_id INTEGER);
|
||||
INSERT INTO message_context VALUES (1, 1, NULL);
|
||||
`)
|
||||
} finally {
|
||||
legacyDb.close()
|
||||
}
|
||||
|
||||
const currentPath = path.join(dbDir, 'v10.db')
|
||||
const currentDb = new Database(currentPath)
|
||||
try {
|
||||
currentDb.exec(CHAT_DB_TABLES)
|
||||
currentDb.exec(`
|
||||
INSERT INTO meta (name, platform, type, imported_at)
|
||||
VALUES ('v10 chat', 'qq', 'group', 1000);
|
||||
INSERT INTO member (id, platform_id, account_name) VALUES (1, 'new-user', 'Bob');
|
||||
INSERT INTO message (id, sender_id, ts, type, content) VALUES (1, 1, 1000, 0, 'hello');
|
||||
INSERT INTO segment (id, start_ts, end_ts, message_count, summary, summary_message_count)
|
||||
VALUES (1, 1000, 1000, 1, 'Verified summary', 1);
|
||||
INSERT INTO message_context (message_id, segment_id, topic_id) VALUES (1, 1, NULL);
|
||||
`)
|
||||
} finally {
|
||||
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)
|
||||
const beforeGate = fs.readFileSync(path.join(dataDir, '.chatlab-meta.json'))
|
||||
|
||||
const dbManager = new DatabaseManager(pathProvider, {
|
||||
runtime: { version: '0.37.6', kind: 'mcp' },
|
||||
})
|
||||
t.after(() => dbManager.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, dbManager)
|
||||
await server.connect(serverTransport)
|
||||
await client.connect(clientTransport)
|
||||
|
||||
const oldResult = await client.callTool({
|
||||
name: 'chatlab_get_segment_summaries',
|
||||
arguments: { session_id: 'v9', format: 'json' },
|
||||
})
|
||||
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)
|
||||
|
||||
const currentResult = await client.callTool({
|
||||
name: 'chatlab_get_segment_summaries',
|
||||
arguments: { session_id: 'v10', format: 'json' },
|
||||
})
|
||||
assert.equal(currentResult.isError, undefined, JSON.stringify(currentResult.content))
|
||||
const parsed = JSON.parse((currentResult.content as Array<{ text: string }>)[0].text)
|
||||
assert.equal(parsed.total, 1)
|
||||
assert.equal(parsed.segments[0].summary, 'Verified summary')
|
||||
assert.deepEqual(parsed.segments[0].participants, ['Bob'])
|
||||
assert.deepEqual(fs.readFileSync(currentPath), beforeV10)
|
||||
assert.deepEqual(fs.readFileSync(path.join(dataDir, '.chatlab-meta.json')), beforeGate)
|
||||
} finally {
|
||||
await client.close()
|
||||
await server.close()
|
||||
}
|
||||
|
||||
const verifyLegacy = new Database(legacyPath, { readonly: true })
|
||||
try {
|
||||
assert.equal(
|
||||
(verifyLegacy.prepare('SELECT schema_version FROM meta').get() as { schema_version: number }).schema_version,
|
||||
9
|
||||
)
|
||||
assert.equal(
|
||||
verifyLegacy.prepare("SELECT name FROM pragma_table_info('segment') WHERE name='summary_message_count'").get(),
|
||||
undefined
|
||||
)
|
||||
} finally {
|
||||
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, 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 {
|
||||
await client.close()
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
@@ -14,9 +14,20 @@ import type { SessionListContext } from '@openchatlab/tools/src/definitions/sess
|
||||
import type { McpDatabaseManager, McpServerOptions } from './types'
|
||||
import { jsonSchemaToZod } from './schema'
|
||||
import { formatToolResultAsText } from './format'
|
||||
import { assertMcpSqlReadOnly } from './sql-guard'
|
||||
|
||||
const MCP_TOOL_PREFIX = 'chatlab_'
|
||||
|
||||
// These tools only read logical chat data, never migrate sessions or modify
|
||||
// the compatibility marker. SQLite's ordinary WAL/SHM coordination can still
|
||||
// change auxiliary filesystem files; that contract distinction is documented
|
||||
// explicitly and left for upstream maintainer approval.
|
||||
const READ_ONLY_TOOL_ANNOTATIONS = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
openWorldHint: false,
|
||||
} as const
|
||||
|
||||
const FORMAT_PARAM = z.enum(['text', 'json']).optional().describe('text (default) or json')
|
||||
|
||||
/** Tools where JSON is a better default (raw SQL results, schema) */
|
||||
@@ -30,7 +41,7 @@ function applyFormat(content: string, format: string | undefined, toolName: stri
|
||||
return textResult ?? content
|
||||
}
|
||||
|
||||
function registerTools(server: McpServer, dbManager: McpDatabaseManager, locale?: string): void {
|
||||
export function registerTools(server: McpServer, dbManager: McpDatabaseManager, locale?: string): void {
|
||||
for (const tool of MCP_TOOL_REGISTRY) {
|
||||
const mcpName = `${MCP_TOOL_PREFIX}${tool.name}`
|
||||
const { description, inputSchema } = getLocalizedToolMetadata(tool, locale)
|
||||
@@ -41,20 +52,26 @@ function registerTools(server: McpServer, dbManager: McpDatabaseManager, locale?
|
||||
format: FORMAT_PARAM,
|
||||
}
|
||||
|
||||
server.tool(mcpName, description, zodShape, async (params) => {
|
||||
const format = params.format as string | undefined
|
||||
const context: SessionListContext = {
|
||||
db: null as any,
|
||||
sessionId: '',
|
||||
listSessionIds: () => dbManager.listSessionIds(),
|
||||
openDb: (id) => dbManager.open(id),
|
||||
server.registerTool(
|
||||
mcpName,
|
||||
{ description, inputSchema: zodShape, annotations: READ_ONLY_TOOL_ANNOTATIONS },
|
||||
async (params) => {
|
||||
const format = params.format as string | undefined
|
||||
const context: SessionListContext = {
|
||||
db: null as any,
|
||||
sessionId: '',
|
||||
listSessionIds: () => dbManager.listSessionIdsMcpReadonly(),
|
||||
openDb: (id) => dbManager.openMcpReadonly(id),
|
||||
// Close each independent session transaction before opening the next.
|
||||
closeDb: (db) => db.close(),
|
||||
}
|
||||
const toolParams = { ...params } as Record<string, unknown>
|
||||
delete toolParams.format
|
||||
const result = await tool.handler(toolParams, context)
|
||||
const text = applyFormat(result.content, format, tool.name)
|
||||
return { content: [{ type: 'text' as const, text }] }
|
||||
}
|
||||
const toolParams = { ...params } as Record<string, unknown>
|
||||
delete toolParams.format
|
||||
const result = await tool.handler(toolParams, context)
|
||||
const text = applyFormat(result.content, format, tool.name)
|
||||
return { content: [{ type: 'text' as const, text }] }
|
||||
})
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -64,38 +81,55 @@ function registerTools(server: McpServer, dbManager: McpDatabaseManager, locale?
|
||||
format: FORMAT_PARAM,
|
||||
}
|
||||
|
||||
server.tool(mcpName, description, zodShape, async (params) => {
|
||||
const sessionId = params.session_id as string
|
||||
const format = params.format as string | undefined
|
||||
const db = dbManager.open(sessionId)
|
||||
if (!db) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Session not found: ${sessionId}` }],
|
||||
isError: true,
|
||||
server.registerTool(
|
||||
mcpName,
|
||||
{ description, inputSchema: zodShape, annotations: READ_ONLY_TOOL_ANNOTATIONS },
|
||||
async (params) => {
|
||||
const sessionId = params.session_id as string
|
||||
const format = params.format as string | undefined
|
||||
// Reject SQL that can have prepare-time side effects before even opening
|
||||
// the SQLite database, which might itself create WAL/SHM sidecars.
|
||||
const rawSql = (params as Record<string, unknown>).sql
|
||||
if ((tool.name === 'execute_sql' || tool.name === 'render_chart') && typeof rawSql === 'string') {
|
||||
assertMcpSqlReadOnly(rawSql)
|
||||
}
|
||||
const db = dbManager.openMcpReadonly(sessionId)
|
||||
if (!db) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Session not found: ${sessionId}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const toolParams = { ...params } as Record<string, unknown>
|
||||
delete toolParams.session_id
|
||||
delete toolParams.format
|
||||
const result = await tool.handler(toolParams, { db, sessionId, dataProvider: new CoreDataProvider(db) })
|
||||
const text = applyFormat(result.content, format, tool.name)
|
||||
return { content: [{ type: 'text' as const, text }] }
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
const toolParams = { ...params } as Record<string, unknown>
|
||||
delete toolParams.session_id
|
||||
delete toolParams.format
|
||||
|
||||
const result = await tool.handler(toolParams, { db, sessionId, dataProvider: new CoreDataProvider(db) })
|
||||
const text = applyFormat(result.content, format, tool.name)
|
||||
return { content: [{ type: 'text' as const, text }] }
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function registerResources(server: McpServer, dbManager: McpDatabaseManager): void {
|
||||
server.resource('sessions-list', 'chatlab://sessions', { description: '所有已导入的聊天会话列表' }, async () => {
|
||||
const sessionIds = dbManager.listSessionIds()
|
||||
const sessionIds = dbManager.listSessionIdsMcpReadonly()
|
||||
const sessions = sessionIds
|
||||
.map((id) => {
|
||||
const db = dbManager.open(id)
|
||||
const db = dbManager.openMcpReadonly(id)
|
||||
if (!db) return null
|
||||
const meta = getSessionMeta(db)
|
||||
if (!meta) return null
|
||||
return { id, name: meta.name, platform: meta.platform, type: meta.type }
|
||||
try {
|
||||
const meta = getSessionMeta(db)
|
||||
if (!meta) return null
|
||||
return { id, name: meta.name, platform: meta.platform, type: meta.type }
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -116,22 +150,26 @@ function registerResources(server: McpServer, dbManager: McpDatabaseManager): vo
|
||||
{ description: '会话元信息(名称、平台、消息数等)' },
|
||||
async (uri, params) => {
|
||||
const sessionId = params.sessionId as string
|
||||
const db = dbManager.open(sessionId)
|
||||
const db = dbManager.openMcpReadonly(sessionId)
|
||||
if (!db) {
|
||||
return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] }
|
||||
}
|
||||
|
||||
const meta = getSessionMeta(db)
|
||||
const overview = getSessionOverview(db)
|
||||
try {
|
||||
const meta = getSessionMeta(db)
|
||||
const overview = getSessionOverview(db)
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
text: JSON.stringify({ ...meta, ...overview }, null, 2),
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
],
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
text: JSON.stringify({ ...meta, ...overview }, null, 2),
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
],
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -142,21 +180,25 @@ function registerResources(server: McpServer, dbManager: McpDatabaseManager): vo
|
||||
{ description: '会话数据库的表结构' },
|
||||
async (uri, params) => {
|
||||
const sessionId = params.sessionId as string
|
||||
const db = dbManager.open(sessionId)
|
||||
const db = dbManager.openMcpReadonly(sessionId)
|
||||
if (!db) {
|
||||
return { contents: [{ uri: uri.href, text: '{"error": "Session not found"}', mimeType: 'application/json' }] }
|
||||
}
|
||||
|
||||
const schema = getDatabaseSchema(db)
|
||||
try {
|
||||
const schema = getDatabaseSchema(db)
|
||||
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
text: JSON.stringify(schema, null, 2),
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
],
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
text: JSON.stringify(schema, null, 2),
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
],
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
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 { CHAT_DB_TABLES } from '@openchatlab/core'
|
||||
import type { DatabaseAdapter } from '@openchatlab/core'
|
||||
import { DatabaseManager, NodePathProvider } from '@openchatlab/node-runtime'
|
||||
import { startMcpServer } from './server'
|
||||
import { openTestSqliteDatabase } from '../../../tests/helpers/sqlite.mts'
|
||||
|
||||
test('MCP list_sessions closes each read transaction before opening the next one', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(process.cwd(), '.chatlab-mcp-list-lifecycle-'))
|
||||
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 firstPath = path.join(dbDir, 'first.db')
|
||||
const fixture = openTestSqliteDatabase(firstPath)
|
||||
try {
|
||||
fixture.pragma('journal_mode = WAL')
|
||||
fixture.exec(CHAT_DB_TABLES)
|
||||
fixture.exec(`
|
||||
INSERT INTO meta (name, platform, type, imported_at)
|
||||
VALUES ('Session One', 'qq', 'group', 1000);
|
||||
`)
|
||||
} finally {
|
||||
fixture.close()
|
||||
}
|
||||
const secondPath = path.join(dbDir, 'second.db')
|
||||
fs.copyFileSync(firstPath, secondPath)
|
||||
const second = openTestSqliteDatabase(secondPath)
|
||||
try {
|
||||
second.exec("UPDATE meta SET name = 'Session Two'")
|
||||
} finally {
|
||||
second.close()
|
||||
}
|
||||
|
||||
const pathProvider = new NodePathProvider(dataDir)
|
||||
const manager = new DatabaseManager(pathProvider, { runtime: { version: '0.37.6', kind: 'mcp' } })
|
||||
t.after(() => manager.closeAll())
|
||||
manager.raiseCurrentChatDbCompatibilityGate()
|
||||
const markerPath = path.join(dataDir, '.chatlab-meta.json')
|
||||
const beforeMarker = fs.readFileSync(markerPath)
|
||||
|
||||
let activeConnections = 0
|
||||
let peakConnections = 0
|
||||
let sessionsOpened = 0
|
||||
let sessionsClosed = 0
|
||||
const trackedManager = {
|
||||
listSessionIdsMcpReadonly: () => manager.listSessionIdsMcpReadonly(),
|
||||
openMcpReadonly(sessionId: string): DatabaseAdapter | null {
|
||||
const db = manager.openMcpReadonly(sessionId)
|
||||
if (!db) return null
|
||||
activeConnections++
|
||||
sessionsOpened++
|
||||
peakConnections = Math.max(peakConnections, activeConnections)
|
||||
return {
|
||||
readonly: db.readonly,
|
||||
exec: (sql) => db.exec(sql),
|
||||
prepare: (sql) => db.prepare(sql),
|
||||
transaction: (fn) => db.transaction(fn),
|
||||
pragma: (pragma) => db.pragma(pragma),
|
||||
close() {
|
||||
db.close()
|
||||
activeConnections--
|
||||
sessionsClosed++
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const client = new Client({ name: 'session-list-lifetime-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: trackedManager })
|
||||
await client.connect(clientTransport)
|
||||
try {
|
||||
const result = await client.callTool({ name: 'chatlab_list_sessions', arguments: { format: 'json' } })
|
||||
assert.equal(result.isError, undefined, JSON.stringify(result.content))
|
||||
const parsed = JSON.parse((result.content as Array<{ text: string }>)[0].text)
|
||||
assert.equal(parsed.total, 2)
|
||||
assert.deepEqual(parsed.sessions.map((s: { id: string }) => s.id).sort(), ['first', 'second'])
|
||||
assert.equal(peakConnections, 1, 'a second session connection opened before the previous one was closed')
|
||||
assert.equal(activeConnections, 0)
|
||||
assert.equal(sessionsOpened, 2)
|
||||
assert.equal(sessionsClosed, 2)
|
||||
|
||||
const resource = await client.readResource({ uri: 'chatlab://sessions' })
|
||||
assert.match(JSON.stringify(resource.contents), /Session One/)
|
||||
assert.match(JSON.stringify(resource.contents), /Session Two/)
|
||||
assert.ok(fs.readdirSync(dbDir).includes('first.db'))
|
||||
assert.ok(fs.readdirSync(dbDir).includes('second.db'))
|
||||
assert.deepEqual(fs.readFileSync(markerPath), beforeMarker)
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* MCP-only guard for user-provided SQL. SQLite can change global settings
|
||||
* during prepare() (e.g. some PRAGMAs), before stmt.readonly can be checked.
|
||||
* This lexical precheck is deliberately narrow, not a replacement for SQLite's
|
||||
* native single-statement/read-only verification in the shared SQL executor.
|
||||
*/
|
||||
export function assertMcpSqlReadOnly(sql: string): void {
|
||||
let pos = 0
|
||||
while (pos < sql.length) {
|
||||
while (pos < sql.length && /\s/.test(sql[pos])) pos++
|
||||
if (sql.startsWith('--', pos)) {
|
||||
const end = sql.indexOf('\n', pos + 2)
|
||||
pos = end < 0 ? sql.length : end + 1
|
||||
continue
|
||||
}
|
||||
if (sql.startsWith('/*', pos)) {
|
||||
const end = sql.indexOf('*/', pos + 2)
|
||||
if (end < 0) throw new Error('Only SELECT or WITH queries are allowed in MCP')
|
||||
pos = end + 2
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if (!/^(SELECT|WITH)\b/i.test(sql.slice(pos))) {
|
||||
throw new Error('Only SELECT or WITH queries are allowed in MCP')
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import type { DatabaseAdapter } from '@openchatlab/core'
|
||||
* Both CLI's DatabaseManager and Desktop's helper can satisfy this contract.
|
||||
*/
|
||||
export interface McpDatabaseManager {
|
||||
listSessionIds(): string[]
|
||||
open(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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import Database from 'better-sqlite3'
|
||||
import type { PathProvider } from '@openchatlab/core'
|
||||
import type { DatabaseAdapter, PathProvider } from '@openchatlab/core'
|
||||
import { CHAT_DB_SCHEMA, CURRENT_SCHEMA_VERSION, getSessionInfo } from '@openchatlab/core'
|
||||
import { DataDirCompatibilityError, readDataDirCompatibilityMeta } from './data-dir-compat'
|
||||
import { DatabaseManager, listDatabaseCandidateIds } from './database-manager'
|
||||
@@ -1328,3 +1328,263 @@ 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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('MCP live WAL reads use one committed SQLite snapshot across multiple SELECTs', () => {
|
||||
const root = makeTempDir()
|
||||
const provider = createPathProvider(root)
|
||||
fs.mkdirSync(provider.getDatabaseDir(), { recursive: true })
|
||||
const dbPath = path.join(provider.getDatabaseDir(), 'concurrent.db')
|
||||
const writer = new Database(dbPath, { nativeBinding })
|
||||
const manager = new DatabaseManager(provider, {
|
||||
nativeBinding,
|
||||
runtime: { version: '0.37.6', kind: 'mcp' },
|
||||
})
|
||||
try {
|
||||
writer.pragma('journal_mode=WAL')
|
||||
writer.exec(CHAT_DB_SCHEMA)
|
||||
writer.exec(`
|
||||
INSERT INTO meta(name, platform, type, imported_at) VALUES('Concurrent', 'qq', 'group', 1000);
|
||||
INSERT INTO member(platform_id, account_name) VALUES('p1', 'Alice');
|
||||
INSERT INTO message(sender_id, ts, type, content) VALUES(1, 1000, 0, 'committed earlier');
|
||||
`)
|
||||
manager.raiseCurrentChatDbCompatibilityGate()
|
||||
const markerPath = path.join(provider.getUserDataDir(), '.chatlab-meta.json')
|
||||
const beforeMarker = fs.readFileSync(markerPath)
|
||||
const count = (db: DatabaseAdapter) => (db.prepare('SELECT count(*) as n FROM message').get() as { n: number }).n
|
||||
const first = manager.openMcpReadonly('concurrent')
|
||||
assert.ok(first)
|
||||
try {
|
||||
assert.equal(first.readonly, true)
|
||||
assert.equal(count(first), 1)
|
||||
writer.exec("INSERT INTO message(sender_id, ts, type, content) VALUES(1, 1001, 0, 'committed later')")
|
||||
// The version check and both queries must see the same transaction view.
|
||||
assert.equal(count(first), 1)
|
||||
const second = manager.openMcpReadonly('concurrent')
|
||||
assert.ok(second)
|
||||
try {
|
||||
assert.equal(count(second), 2)
|
||||
} finally {
|
||||
second.close()
|
||||
}
|
||||
assert.equal(count(first), 1)
|
||||
} finally {
|
||||
first.close()
|
||||
}
|
||||
assert.equal((writer.pragma('wal_checkpoint(TRUNCATE)') as Array<{ busy: number }>)[0].busy, 0)
|
||||
assert.deepEqual(fs.readFileSync(markerPath), beforeMarker)
|
||||
} finally {
|
||||
manager.closeAll()
|
||||
writer.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('MCP does not return uncommitted changes from a rollback-journal writer', () => {
|
||||
const root = makeTempDir()
|
||||
const provider = createPathProvider(root)
|
||||
fs.mkdirSync(provider.getDatabaseDir(), { recursive: true })
|
||||
const dbPath = path.join(provider.getDatabaseDir(), 'rollback.db')
|
||||
const writer = new Database(dbPath, { nativeBinding })
|
||||
const manager = new DatabaseManager(provider, {
|
||||
nativeBinding,
|
||||
runtime: { version: '0.37.6', kind: 'mcp' },
|
||||
})
|
||||
try {
|
||||
writer.exec(CHAT_DB_SCHEMA)
|
||||
writer.exec(`
|
||||
INSERT INTO meta(name, platform, type, imported_at) VALUES('Rollback', 'qq', 'group', 1000);
|
||||
INSERT INTO member(platform_id, account_name) VALUES('p1', 'Alice');
|
||||
`)
|
||||
writer.pragma('journal_mode=DELETE')
|
||||
writer.pragma('cache_size=10')
|
||||
writer.pragma('cache_spill=ON')
|
||||
const insert = writer.prepare('INSERT INTO message(sender_id, ts, type, content) VALUES(1, ?, 0, ?)')
|
||||
writer.transaction(() => {
|
||||
for (let i = 0; i < 90; i++) insert.run(i, 'committed '.padEnd(1600, 'a'))
|
||||
})()
|
||||
manager.raiseCurrentChatDbCompatibilityGate()
|
||||
writer.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
writer.exec("UPDATE message SET content = 'UNCOMMITTED ' || content")
|
||||
assert.equal(fs.existsSync(dbPath + '-journal'), true)
|
||||
try {
|
||||
const reader = manager.openMcpReadonly('rollback')
|
||||
if (reader) {
|
||||
try {
|
||||
const uncommitted = reader
|
||||
.prepare("SELECT count(*) AS n FROM message WHERE content LIKE 'UNCOMMITTED%'")
|
||||
.get() as { n: number }
|
||||
assert.equal(uncommitted.n, 0, 'a live MCP reader must never expose uncommitted rollback pages')
|
||||
} finally {
|
||||
reader.close()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
assert.match(String(error), /BUSY|locked/i, 'only a SQLite lock error is acceptable')
|
||||
}
|
||||
} finally {
|
||||
writer.exec('ROLLBACK')
|
||||
}
|
||||
const clean = manager.openMcpReadonly('rollback')
|
||||
assert.ok(clean)
|
||||
try {
|
||||
assert.equal(
|
||||
(clean.prepare("SELECT count(*) AS n FROM message WHERE content LIKE 'UNCOMMITTED%'").get() as { n: number }).n,
|
||||
0
|
||||
)
|
||||
} finally {
|
||||
clean.close()
|
||||
}
|
||||
} finally {
|
||||
manager.closeAll()
|
||||
writer.close()
|
||||
}
|
||||
})
|
||||
@@ -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 {
|
||||
@@ -58,6 +64,14 @@ export function listDatabaseCandidateIds(databaseDir: string): string[] {
|
||||
.map((fileName) => fileName.slice(0, -3))
|
||||
}
|
||||
|
||||
/** A non-ChatLab SQLite file may be skipped during discovery, not opened directly. */
|
||||
class NotChatLabSessionError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Not a ChatLab session database: ${sessionId}`)
|
||||
this.name = 'NotChatLabSessionError'
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseManager {
|
||||
private cache = new Map<string, DatabaseAdapter>()
|
||||
private nativeBinding?: string
|
||||
@@ -126,6 +140,109 @@ export class DatabaseManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate MCP session paths without tightening the shared CLI/desktop APIs.
|
||||
* Reject out-of-scope files, directory traversal, symlinks and non-files.
|
||||
*/
|
||||
private resolveMcpSessionPath(sessionId: string): string | null {
|
||||
if (
|
||||
!sessionId ||
|
||||
sessionId === '.' ||
|
||||
sessionId === '..' ||
|
||||
/[\\/:]/.test(sessionId) ||
|
||||
[...sessionId].some((character) => character.charCodeAt(0) < 32) ||
|
||||
/[. ]$/.test(sessionId) ||
|
||||
path.isAbsolute(sessionId) ||
|
||||
path.win32.isAbsolute(sessionId)
|
||||
) {
|
||||
throw new Error(`Invalid MCP session ID: ${JSON.stringify(sessionId)}`)
|
||||
}
|
||||
|
||||
const databaseDir = this.pathProvider.getDatabaseDir()
|
||||
const dbPath = path.join(databaseDir, `${sessionId}.db`)
|
||||
let entry: fs.Stats
|
||||
try {
|
||||
entry = fs.lstatSync(dbPath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) {
|
||||
throw new Error(`Invalid MCP session database path: ${sessionId}`)
|
||||
}
|
||||
|
||||
// A configured database root may itself be a symlink. Resolve both sides
|
||||
// and verify the real target is the expected direct child of that root.
|
||||
const root = fs.realpathSync(databaseDir)
|
||||
const actual = fs.realpathSync(dbPath)
|
||||
if (path.relative(root, actual) !== `${sessionId}.db`) {
|
||||
throw new Error(`MCP session database is outside the configured directory: ${sessionId}`)
|
||||
}
|
||||
return dbPath
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP-only live SQLite read. No migrations or marker updates, and no
|
||||
* full-file snapshot: the native read transaction isolates committed data.
|
||||
* Normal WAL coordination may create/update -wal or -shm sidecars.
|
||||
*/
|
||||
openMcpReadonly(sessionId: string): DatabaseAdapter | null {
|
||||
this.assertCompatible()
|
||||
const dbPath = this.resolveMcpSessionPath(sessionId)
|
||||
if (!dbPath) return null
|
||||
const db = openBetterSqliteDatabase(dbPath, { readonly: true, nativeBinding: this.nativeBinding })
|
||||
|
||||
try {
|
||||
// BEGIN is deferred; the first schema query fixes the SQLite read view.
|
||||
// Closing this one-request connection releases the read transaction.
|
||||
db.exec('BEGIN')
|
||||
if (!isChatSessionDb(db)) {
|
||||
throw new NotChatLabSessionError(sessionId)
|
||||
}
|
||||
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()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a session DB in read-write mode and auto-run pending migrations.
|
||||
* Falls back to `open(id, { readonly: false })` if migration is unnecessary.
|
||||
@@ -204,13 +321,15 @@ export class DatabaseManager {
|
||||
|
||||
/**
|
||||
* 只读列举聊天会话 ID,不触发数据库迁移或兼容门禁提升。
|
||||
* CLI 导入预分析需要枚举未升级的旧库,因此不可强制当前 Schema。
|
||||
*/
|
||||
listSessionIdsReadonly(): string[] {
|
||||
this.assertCompatible()
|
||||
|
||||
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 {
|
||||
@@ -219,6 +338,29 @@ 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) => {
|
||||
let db: DatabaseAdapter | null
|
||||
try {
|
||||
db = this.openMcpReadonly(sessionId)
|
||||
} catch (error) {
|
||||
// Discovery may ignore proven non-ChatLab databases, but must not
|
||||
// silently swallow damaged databases or schema/gate/permission errors.
|
||||
if (error instanceof NotChatLabSessionError) return false
|
||||
throw error
|
||||
}
|
||||
if (!db) return false
|
||||
try {
|
||||
return true // openMcpReadonly has already validated its type and schema.
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getUserDataDir(): string {
|
||||
return this.pathProvider.getUserDataDir()
|
||||
}
|
||||
@@ -296,6 +438,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)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import type { DatabaseAdapter } from '@openchatlab/core'
|
||||
import { sessionsListTool, type SessionListContext } from './sessions'
|
||||
|
||||
test('list_sessions closes each per-session database even when metadata query throws', () => {
|
||||
let closeCount = 0
|
||||
const fakeDb: DatabaseAdapter = {
|
||||
readonly: true,
|
||||
prepare: () => {
|
||||
throw new Error('synthetic database query failure')
|
||||
},
|
||||
pragma: () => [],
|
||||
exec: () => undefined,
|
||||
transaction: (fn) => fn(),
|
||||
close: () => {
|
||||
closeCount++
|
||||
},
|
||||
}
|
||||
const context: SessionListContext = {
|
||||
db: fakeDb,
|
||||
sessionId: '',
|
||||
listSessionIds: () => ['one'],
|
||||
openDb: () => fakeDb,
|
||||
closeDb: (db) => db.close(),
|
||||
}
|
||||
assert.throws(() => sessionsListTool.handler({}, context), /synthetic database query failure/)
|
||||
assert.equal(closeCount, 1)
|
||||
})
|
||||
@@ -22,6 +22,8 @@ const inputSchema: JsonSchema = {
|
||||
export interface SessionListContext extends ToolExecutionContext {
|
||||
listSessionIds: () => string[]
|
||||
openDb: (sessionId: string) => DatabaseAdapter | null
|
||||
/** Optional lifecycle hook for uncached per-session connections (e.g. MCP read transactions). */
|
||||
closeDb?: (db: DatabaseAdapter) => void
|
||||
}
|
||||
|
||||
function handler(_params: Record<string, unknown>, context: SessionListContext): ToolResult {
|
||||
@@ -32,11 +34,15 @@ function handler(_params: Record<string, unknown>, context: SessionListContext):
|
||||
.map((id) => {
|
||||
const db = context.openDb(id)
|
||||
if (!db) return null
|
||||
const meta = getSessionMeta(db)
|
||||
if (!meta) return null
|
||||
if (keyword && !meta.name.toLowerCase().includes(keyword)) return null
|
||||
const overview = getSessionOverview(db)
|
||||
return { id, ...meta, ...overview }
|
||||
try {
|
||||
const meta = getSessionMeta(db)
|
||||
if (!meta) return null
|
||||
if (keyword && !meta.name.toLowerCase().includes(keyword)) return null
|
||||
const overview = getSessionOverview(db)
|
||||
return { id, ...meta, ...overview }
|
||||
} finally {
|
||||
context.closeDb?.(db)
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
|
||||
@@ -34,11 +34,15 @@ test('MCP tools/list array schemas agree with tools/call validation and database
|
||||
(4, 1, 400, 0, 'second neighbor'),
|
||||
(5, 1, 500, 0, 'beta topic');
|
||||
`)
|
||||
// The production MCP server closes each read-only handle after a tool call.
|
||||
// Reuse this in-memory fixture without closing its underlying test database.
|
||||
const readonlyFixture = Object.create(db) as typeof db
|
||||
readonlyFixture.close = () => {}
|
||||
await startMcpServer({
|
||||
version: '1.0.0',
|
||||
dbManager: {
|
||||
listSessionIds: () => ['fixture'],
|
||||
open: (id) => (id === 'fixture' ? db : null),
|
||||
listSessionIdsMcpReadonly: () => ['fixture'],
|
||||
openMcpReadonly: (id) => (id === 'fixture' ? readonlyFixture : null),
|
||||
},
|
||||
})
|
||||
await client.connect(clientTransport)
|
||||
|
||||
Reference in new issue
Block a user