Compare commits

...
6 Commits
15 changed files with 1554 additions and 72 deletions

No files matched your search

+4 -1
View File
@@ -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 ?? {}
+3 -1
View File
@@ -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()
}
})
+163
View File
@@ -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()
}
})
+375
View File
@@ -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()
}
})
+99 -57
View File
@@ -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()
}
})
+27
View File
@@ -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')
}
}
+4 -2
View File
@@ -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 {
+4 -1
View File
@@ -36,7 +36,10 @@ export interface AssertDataDirCompatibilityOptions {
env?: Pick<NodeJS.ProcessEnv, 'CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR'>
}
type DataDirCompatibilityErrorCode = 'DATA_DIR_REQUIRES_NEWER_RUNTIME' | 'DATA_DIR_COMPATIBILITY_META_INVALID'
type DataDirCompatibilityErrorCode =
| 'DATA_DIR_REQUIRES_NEWER_RUNTIME'
| 'DATA_DIR_COMPATIBILITY_META_INVALID'
| 'DATA_DIR_COMPATIBILITY_REPAIR_REQUIRED'
export class DataDirCompatibilityError extends Error {
readonly code: DataDirCompatibilityErrorCode
@@ -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()
}
})
+176 -2
View File
@@ -19,7 +19,13 @@ import {
} from '@openchatlab/core'
import { openBetterSqliteDatabase } from './better-sqlite3-adapter'
import { deleteSessionCache } from './cache/session-cache'
import { assertDataDirCompatible, type RuntimeIdentity } from './data-dir-compat'
import {
assertDataDirCompatible,
DataDirCompatibilityError,
isRuntimeVersionAtLeast,
readDataDirCompatibilityMeta,
type RuntimeIdentity,
} from './data-dir-compat'
import { CHAT_DB_COMPATIBILITY_RAISES, getChatDbMigrations, raiseChatDbCompatibilityGate } from './migrations'
import { getContactsFactsCacheDir } from './services/contacts/paths'
import {
@@ -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)
})
+11 -5
View File
@@ -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)
+6 -2
View File
@@ -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)