dbmole reported a hardcoded SERVER_VERSION of 0.1.0 while the package was already published as 0.1.1: the constant had drifted because a bump has to touch two places. Both servers now read the version from package.json (resolveJsonModule + a JSON import attribute), so a release updates one place and the reported version can no longer fall behind. Add a dbmole guard test that add_connection publishes a non-empty inputSchema with the expected required fields, mirroring esmole, so a future `.superRefine` on the schema cannot silently empty it.
153 lines
6.2 KiB
TypeScript
153 lines
6.2 KiB
TypeScript
import { mkdtempSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import pkg from '../../package.json' with { type: 'json' }
|
|
import { createManager } from '../../src/manager.js'
|
|
import { createRegistry } from '../../src/registry.js'
|
|
import { createServer, SERVER_VERSION } from '../../src/server.js'
|
|
|
|
const connectClient = async (server: McpServer): Promise<Client> => {
|
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
|
const client = new Client({ name: 'test-client', version: '0.0.0' })
|
|
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
|
|
return client
|
|
}
|
|
|
|
describe('createServer', () => {
|
|
let dir: string
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(join(tmpdir(), 'esmole-server-'))
|
|
})
|
|
|
|
afterEach(() => {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
it('reports the package version over MCP', () => {
|
|
expect(SERVER_VERSION).toBe(pkg.version)
|
|
})
|
|
|
|
it('registers exactly the ten tools and names the server esmole-mcp', async () => {
|
|
const registry = createRegistry({ storePath: join(dir, 'connections.json'), env: {} })
|
|
const manager = createManager(registry)
|
|
const server = createServer(registry, manager)
|
|
const client = await connectClient(server)
|
|
const { tools } = await client.listTools()
|
|
expect(tools.map((tool) => tool.name).sort()).toEqual([
|
|
'add_connection',
|
|
'es_cluster_health',
|
|
'es_get_mapping',
|
|
'es_list_indices',
|
|
'es_request',
|
|
'es_search',
|
|
'list_connections',
|
|
'remove_connection',
|
|
'test_connection',
|
|
'update_connection'
|
|
])
|
|
expect(client.getServerVersion()?.name).toBe('esmole-mcp')
|
|
await client.close()
|
|
})
|
|
|
|
it('publishes a typed inputSchema for add_connection, not an empty object', async () => {
|
|
const registry = createRegistry({ storePath: join(dir, 'connections.json'), env: {} })
|
|
const server = createServer(registry, createManager(registry))
|
|
const client = await connectClient(server)
|
|
const { tools } = await client.listTools()
|
|
const add = tools.find((tool) => tool.name === 'add_connection')
|
|
const schema = add?.inputSchema
|
|
expect(Object.keys(schema?.properties ?? {})).toEqual(
|
|
expect.arrayContaining([
|
|
'name',
|
|
'host',
|
|
'port',
|
|
'user',
|
|
'password',
|
|
'type',
|
|
'scheme',
|
|
'verifyTls',
|
|
'apiKey'
|
|
])
|
|
)
|
|
expect(schema?.required ?? []).toEqual(expect.arrayContaining(['name', 'host', 'type']))
|
|
expect(schema?.additionalProperties).toBe(false)
|
|
await client.close()
|
|
})
|
|
|
|
it('add_connection still enforces the apiKey-or-user+password cross-field check', async () => {
|
|
const registry = createRegistry({ storePath: join(dir, 'connections.json'), env: {} })
|
|
const server = createServer(registry, createManager(registry))
|
|
const client = await connectClient(server)
|
|
const result = await client.callTool({
|
|
name: 'add_connection',
|
|
arguments: { name: 'es-noauth', host: 'es.local', type: 'elasticsearch' }
|
|
})
|
|
const content = result.content as Array<{ type: string; text: string }>
|
|
expect(result.isError).toBe(true)
|
|
expect(content[0].text).toContain('provide either apiKey or both user and password')
|
|
await client.close()
|
|
})
|
|
|
|
it('add_connection rejects unknown keys at the SDK boundary, not in the handler', async () => {
|
|
const registry = createRegistry({ storePath: join(dir, 'connections.json'), env: {} })
|
|
const server = createServer(registry, createManager(registry))
|
|
const client = await connectClient(server)
|
|
const result = await client.callTool({
|
|
name: 'add_connection',
|
|
arguments: {
|
|
name: 'es-bogus',
|
|
host: 'es.local',
|
|
type: 'elasticsearch',
|
|
apiKey: 'x',
|
|
bogus: 1
|
|
}
|
|
})
|
|
const content = result.content as Array<{ type: string; text: string }>
|
|
expect(result.isError).toBe(true)
|
|
// The SDK-level "Invalid arguments for tool" prefix proves the strict wire
|
|
// schema rejected the unknown key before the handler ran, rather than the
|
|
// key being silently stripped or caught only by the handler's parseSchema.
|
|
expect(content[0].text).toContain('Invalid arguments for tool add_connection')
|
|
expect(content[0].text).toContain('Unrecognized key')
|
|
await client.close()
|
|
})
|
|
|
|
it('list_connections renders the public view of a stored connection', async () => {
|
|
const registry = createRegistry({ storePath: join(dir, 'connections.json'), env: {} })
|
|
registry.add({
|
|
name: 'prod',
|
|
type: 'elasticsearch',
|
|
host: 'es.example.com',
|
|
apiKey: 'secret-key',
|
|
scheme: 'https',
|
|
verifyTls: true,
|
|
readonly: false
|
|
})
|
|
const server = createServer(registry, createManager(registry))
|
|
const client = await connectClient(server)
|
|
const result = await client.callTool({ name: 'list_connections', arguments: {} })
|
|
const content = result.content as Array<{ type: string; text: string }>
|
|
const view = JSON.parse(content[0].text)
|
|
expect(view).toEqual([
|
|
{
|
|
name: 'prod',
|
|
type: 'elasticsearch',
|
|
scheme: 'https',
|
|
host: 'es.example.com',
|
|
port: 9200,
|
|
auth: 'apiKey',
|
|
verifyTls: true,
|
|
readonly: false,
|
|
source: 'store',
|
|
ssh: false
|
|
}
|
|
])
|
|
await client.close()
|
|
})
|
|
})
|