fix(mcp): mark query tools as read-only

This commit is contained in:
moesnow authored and digua committed 2026-10-24 16:26:22 +08:00
1 parent fa9a62f772
commit 395f124299
2 files changed
+84 -31

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
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 { MCP_TOOL_REGISTRY } from '@openchatlab/tools'
import { registerTools } from './server'
test('advertises every ChatLab MCP query tool 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, { listSessionIds: () => [], open: () => 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()
}
})
+45 -31
View File
@@ -17,6 +17,12 @@ import { formatToolResultAsText } from './format'
const MCP_TOOL_PREFIX = 'chatlab_'
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 +36,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 +47,24 @@ 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.listSessionIds(),
openDb: (id) => dbManager.open(id),
}
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,25 +74,29 @@ 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
const db = dbManager.open(sessionId)
if (!db) {
return {
content: [{ type: 'text' as const, text: `Session not found: ${sessionId}` }],
isError: true,
}
}
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 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 }] }
})
)
}
}