mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(mcp): avoid migrations during read-only queries
This commit is contained in:
1 parent
395f124299
commit
eda2694261
5 files changed
+183
-48
No files matched your search
@@ -1,9 +1,14 @@
|
||||
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 { MCP_TOOL_REGISTRY } from '@openchatlab/tools'
|
||||
import { DatabaseManager, NodePathProvider, readDataDirCompatibilityMeta } from '@openchatlab/node-runtime'
|
||||
import { registerTools } from './server'
|
||||
|
||||
test('advertises every ChatLab MCP query tool as read-only', async () => {
|
||||
@@ -12,7 +17,7 @@ test('advertises every ChatLab MCP query tool as read-only', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
||||
|
||||
try {
|
||||
registerTools(server, { listSessionIds: () => [], open: () => null })
|
||||
registerTools(server, { listSessionIdsReadonly: () => [], openReadonly: () => null })
|
||||
await server.connect(serverTransport)
|
||||
await client.connect(clientTransport)
|
||||
|
||||
@@ -37,3 +42,89 @@ test('advertises every ChatLab MCP query tool as read-only', async () => {
|
||||
await server.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('MCP queries do not migrate a pending-schema session or raise the data 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, undefined, `${name}: ${JSON.stringify(result.content)}`)
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mc
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { z } from 'zod'
|
||||
import { getSessionMeta, getSessionOverview, getDatabaseSchema } from '@openchatlab/core'
|
||||
import type { DatabaseAdapter } from '@openchatlab/core'
|
||||
import { MCP_TOOL_REGISTRY, CoreDataProvider, getLocalizedToolMetadata } from '@openchatlab/tools'
|
||||
import type { SessionListContext } from '@openchatlab/tools/src/definitions/sessions'
|
||||
import type { McpDatabaseManager, McpServerOptions } from './types'
|
||||
@@ -52,17 +53,28 @@ export function registerTools(server: McpServer, dbManager: McpDatabaseManager,
|
||||
{ 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.listSessionIds(),
|
||||
openDb: (id) => dbManager.open(id),
|
||||
// The shared list handler opens multiple databases; close every connection
|
||||
// after it returns, including when a query throws.
|
||||
const openedDbs: DatabaseAdapter[] = []
|
||||
try {
|
||||
const context: SessionListContext = {
|
||||
db: null as any,
|
||||
sessionId: '',
|
||||
listSessionIds: () => dbManager.listSessionIdsReadonly(),
|
||||
openDb: (id) => {
|
||||
const db = dbManager.openReadonly(id)
|
||||
if (db) openedDbs.push(db)
|
||||
return db
|
||||
},
|
||||
}
|
||||
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 }] }
|
||||
} finally {
|
||||
for (const db of openedDbs) 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 }] }
|
||||
}
|
||||
)
|
||||
continue
|
||||
@@ -80,7 +92,7 @@ export function registerTools(server: McpServer, dbManager: McpDatabaseManager,
|
||||
async (params) => {
|
||||
const sessionId = params.session_id as string
|
||||
const format = params.format as string | undefined
|
||||
const db = dbManager.open(sessionId)
|
||||
const db = dbManager.openReadonly(sessionId)
|
||||
if (!db) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Session not found: ${sessionId}` }],
|
||||
@@ -88,13 +100,17 @@ export function registerTools(server: McpServer, dbManager: McpDatabaseManager,
|
||||
}
|
||||
}
|
||||
|
||||
const toolParams = { ...params } as Record<string, unknown>
|
||||
delete toolParams.session_id
|
||||
delete toolParams.format
|
||||
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 }] }
|
||||
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()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -102,14 +118,18 @@ export function registerTools(server: McpServer, dbManager: McpDatabaseManager,
|
||||
|
||||
function registerResources(server: McpServer, dbManager: McpDatabaseManager): void {
|
||||
server.resource('sessions-list', 'chatlab://sessions', { description: '所有已导入的聊天会话列表' }, async () => {
|
||||
const sessionIds = dbManager.listSessionIds()
|
||||
const sessionIds = dbManager.listSessionIdsReadonly()
|
||||
const sessions = sessionIds
|
||||
.map((id) => {
|
||||
const db = dbManager.open(id)
|
||||
const db = dbManager.openReadonly(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)
|
||||
|
||||
@@ -130,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.openReadonly(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()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -156,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.openReadonly(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()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 chat sessions without running schema migrations. */
|
||||
listSessionIdsReadonly(): string[]
|
||||
/** Open a non-cached, strictly read-only connection; caller must close it. */
|
||||
openReadonly(sessionId: string): DatabaseAdapter | null
|
||||
}
|
||||
|
||||
export interface McpServerOptions {
|
||||
|
||||
@@ -126,6 +126,16 @@ export class DatabaseManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an uncached session database strictly for reading, without migrations or
|
||||
* compatibility gate updates. 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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a session DB in read-write mode and auto-run pending migrations.
|
||||
* Falls back to `open(id, { readonly: false })` if migration is unnecessary.
|
||||
|
||||
@@ -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),
|
||||
listSessionIdsReadonly: () => ['fixture'],
|
||||
openReadonly: (id) => (id === 'fixture' ? readonlyFixture : null),
|
||||
},
|
||||
})
|
||||
await client.connect(clientTransport)
|
||||
|
||||
Reference in new issue
Block a user