diff --git a/packages/esmole-mcp/src/index.ts b/packages/esmole-mcp/src/index.ts index 336ce12..50af70d 100644 --- a/packages/esmole-mcp/src/index.ts +++ b/packages/esmole-mcp/src/index.ts @@ -1 +1,36 @@ -export {} +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { parseArgs } from './cli.js' +import { createManager } from './manager.js' +import { createRegistry } from './registry.js' +import { createServer } from './server.js' +import { defaultStorePath } from './store.js' + +const main = async () => { + const { configPath } = parseArgs(process.argv.slice(2)) + const registry = createRegistry({ storePath: defaultStorePath(), configPath }) + const manager = createManager(registry) + const server = createServer(registry, manager) + + let shuttingDown = false + const shutdown = async () => { + if (shuttingDown) { + return + } + shuttingDown = true + await server.close().catch(() => {}) + await manager.disposeAll().catch(() => {}) + process.exit(0) + } + process.on('SIGINT', shutdown) + process.on('SIGTERM', shutdown) + process.stdin.on('close', shutdown) + + await server.connect(new StdioServerTransport()) + console.error('esmole: MCP server listening on stdio') +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error) + console.error('esmole: fatal: ' + message) + process.exit(1) +}) diff --git a/packages/esmole-mcp/src/server.ts b/packages/esmole-mcp/src/server.ts new file mode 100644 index 0000000..45d06d6 --- /dev/null +++ b/packages/esmole-mcp/src/server.ts @@ -0,0 +1,71 @@ +import { registerConnectionTools } from '@dbmole/core' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import * as z from 'zod' +import type { EsBackend } from './es/backend.js' +import { ping } from './esBackend.js' +import { esFormatError } from './esError.js' +import type { EsManager } from './manager.js' +import type { EsRegistry } from './registry.js' +import { + type ConnectionConfig, + connectionConfigSchema, + defaultPort, + type EsResolvedConnection, + sshConfigSchema +} from './schema.js' +import { registerEsTools } from './tools/index.js' + +export const SERVER_VERSION = '0.1.0' + +const patchSchema = z + .object({ + name: z + .string() + .regex(/^[a-zA-Z0-9_-]+$/) + .optional(), + host: z.string().min(1).optional(), + port: z.number().int().positive().nullable().optional(), + user: z.string().min(1).nullable().optional(), + password: z.string().nullable().optional(), + apiKey: z.string().min(1).nullable().optional(), + scheme: z.enum(['http', 'https']).optional(), + verifyTls: z.boolean().optional(), + readonly: z.boolean().optional(), + ssh: sshConfigSchema.nullable().optional() + }) + .strict() + +const publicView = (resolved: EsResolvedConnection) => ({ + name: resolved.config.name, + type: resolved.config.type, + scheme: resolved.config.scheme, + host: resolved.config.host, + port: resolved.config.port ?? defaultPort(), + auth: resolved.config.apiKey ? 'apiKey' : 'basic', + verifyTls: resolved.config.verifyTls, + readonly: resolved.config.readonly, + source: resolved.source, + ssh: Boolean(resolved.config.ssh) +}) + +export const createServer = (registry: EsRegistry, manager: EsManager): McpServer => { + const server = new McpServer({ name: 'esmole-mcp', version: SERVER_VERSION }) + registerConnectionTools(server, { + registry, + manager, + fullSchema: connectionConfigSchema, + patchSchema, + publicView, + formatError: esFormatError, + ping, + descriptions: { + list: 'List configured Elasticsearch connections with their source layer (env/config/store). Secrets are omitted.', + add: 'Add a named Elasticsearch connection to the persistent store. Auth via user/password or apiKey; optional ssh tunnel, readonly flag, scheme and verifyTls.', + update: 'Patch a stored ES connection by name. Set a field to null to remove it. Only store-sourced connections can be edited.', + remove: 'Remove a stored ES connection by name and drop its cached client/tunnel.', + test: 'Verify an ES connection works end to end (including ssh tunnel). Reports server version and latency.' + } + }) + registerEsTools(server, manager) + return server +} diff --git a/packages/esmole-mcp/test/unit/server.test.ts b/packages/esmole-mcp/test/unit/server.test.ts new file mode 100644 index 0000000..40f714a --- /dev/null +++ b/packages/esmole-mcp/test/unit/server.test.ts @@ -0,0 +1,88 @@ +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 { 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 => { + 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('exports SERVER_VERSION', () => { + expect(SERVER_VERSION).toBe('0.1.0') + }) + + 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('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() + }) +})