diff --git a/packages/esmole-mcp/src/cli.ts b/packages/esmole-mcp/src/cli.ts new file mode 100644 index 0000000..3226005 --- /dev/null +++ b/packages/esmole-mcp/src/cli.ts @@ -0,0 +1,10 @@ +export const parseArgs = ( + argv: string[], + env: NodeJS.ProcessEnv = process.env +): { configPath?: string } => { + const flagIndex = argv.indexOf('--config') + if (flagIndex !== -1 && argv[flagIndex + 1]) { + return { configPath: argv[flagIndex + 1] } + } + return { configPath: env.ESMOLE_CONFIG } +} diff --git a/packages/esmole-mcp/src/esBackend.ts b/packages/esmole-mcp/src/esBackend.ts new file mode 100644 index 0000000..b106e45 --- /dev/null +++ b/packages/esmole-mcp/src/esBackend.ts @@ -0,0 +1,15 @@ +import type { BackendTarget } from '@dbmole/core' +import { createEsBackend, type EsBackend } from './es/backend.js' +import { type ConnectionConfig, defaultPort } from './schema.js' + +export const resolvePort = (config: ConnectionConfig): number => config.port ?? defaultPort() + +export const createBackend = (target: BackendTarget): EsBackend => + createEsBackend(target) + +// test_connection ping: GET / returns version.number on real ES. +export const ping = async (managed: { backend: EsBackend }): Promise> => { + const res = await managed.backend.request('GET', '/') + const version = (res.body as { version?: { number?: string } })?.version?.number + return { status: res.status, version: version ?? null } +} diff --git a/packages/esmole-mcp/src/manager.ts b/packages/esmole-mcp/src/manager.ts new file mode 100644 index 0000000..e3af297 --- /dev/null +++ b/packages/esmole-mcp/src/manager.ts @@ -0,0 +1,20 @@ +import { + createManager as coreCreate, + type ManagedConnection, + type Manager, + openTunnel +} from '@dbmole/core' +import type { EsBackend } from './es/backend.js' +import { createBackend, resolvePort } from './esBackend.js' +import type { EsRegistry } from './registry.js' +import type { ConnectionConfig } from './schema.js' + +export type EsManager = Manager +export type EsManaged = ManagedConnection + +export const createManager = (registry: EsRegistry): EsManager => + coreCreate(registry, { + createBackend, + resolvePort, + createTunnel: (ssh, host, port) => openTunnel(ssh, host, port, 'esmole') + }) diff --git a/packages/esmole-mcp/src/registry.ts b/packages/esmole-mcp/src/registry.ts new file mode 100644 index 0000000..c0a0d71 --- /dev/null +++ b/packages/esmole-mcp/src/registry.ts @@ -0,0 +1,18 @@ +import { createRegistry as coreCreate, type Registry } from '@dbmole/core' +import { type ConnectionConfig, connectionConfigSchema } from './schema.js' +import { readConfigFile, readEnvConnections } from './sources.js' +import { readStore, writeStore } from './store.js' + +export type EsRegistry = Registry +export type RegistryOptions = { storePath: string; configPath?: string; env?: NodeJS.ProcessEnv } + +export const createRegistry = (options: RegistryOptions): EsRegistry => { + const env = options.env ?? process.env + return coreCreate({ + schema: connectionConfigSchema, + readStore: () => readStore(options.storePath), + writeStore: (configs) => writeStore(options.storePath, configs), + readConfigFile: () => readConfigFile(options.configPath), + readEnvConnections: () => readEnvConnections(env) + }) +} diff --git a/packages/esmole-mcp/src/sources.ts b/packages/esmole-mcp/src/sources.ts new file mode 100644 index 0000000..43a312c --- /dev/null +++ b/packages/esmole-mcp/src/sources.ts @@ -0,0 +1,14 @@ +import { + readConfigFile as coreReadConfig, + readEnvConnections as coreReadEnv, + type StoreOptions +} from '@dbmole/core' +import { type ConnectionConfig, connectionConfigSchema } from './schema.js' + +const OPTS: StoreOptions = { logLabel: 'esmole' } + +export const readEnvConnections = (env: NodeJS.ProcessEnv = process.env): ConnectionConfig[] => + coreReadEnv(connectionConfigSchema, env, 'ESMOLE_CONNECTIONS', OPTS) + +export const readConfigFile = (path: string | undefined): ConnectionConfig[] => + coreReadConfig(connectionConfigSchema, path, OPTS) diff --git a/packages/esmole-mcp/src/store.ts b/packages/esmole-mcp/src/store.ts new file mode 100644 index 0000000..0dd3d67 --- /dev/null +++ b/packages/esmole-mcp/src/store.ts @@ -0,0 +1,14 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { readStore as coreReadStore, type StoreOptions, writeStore } from '@dbmole/core' +import { type ConnectionConfig, connectionConfigSchema } from './schema.js' + +const OPTS: StoreOptions = { logLabel: 'esmole' } + +export const defaultStorePath = (env: NodeJS.ProcessEnv = process.env): string => + env.ESMOLE_STORE ?? join(homedir(), '.config', 'esmole', 'connections.json') + +export const readStore = (path: string): ConnectionConfig[] => + coreReadStore(connectionConfigSchema, path, OPTS) + +export { writeStore } diff --git a/packages/esmole-mcp/test/unit/cli.test.ts b/packages/esmole-mcp/test/unit/cli.test.ts new file mode 100644 index 0000000..f7a4e3f --- /dev/null +++ b/packages/esmole-mcp/test/unit/cli.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { parseArgs } from '../../src/cli.js' + +describe('parseArgs', () => { + it('reads the path after a --config flag', () => { + expect(parseArgs(['--config', '/etc/esmole.json'], {})).toEqual({ + configPath: '/etc/esmole.json' + }) + }) + + it('falls back to ESMOLE_CONFIG when the flag is absent', () => { + expect(parseArgs([], { ESMOLE_CONFIG: '/env/path.json' })).toEqual({ + configPath: '/env/path.json' + }) + }) + + it('prefers the flag over ESMOLE_CONFIG', () => { + expect(parseArgs(['--config', '/flag.json'], { ESMOLE_CONFIG: '/env.json' })).toEqual({ + configPath: '/flag.json' + }) + }) + + it('ignores a trailing --config with no value', () => { + expect(parseArgs(['--config'], { ESMOLE_CONFIG: '/env.json' })).toEqual({ + configPath: '/env.json' + }) + }) + + it('returns undefined when neither flag nor env is set', () => { + expect(parseArgs([], {})).toEqual({ configPath: undefined }) + }) +}) diff --git a/packages/esmole-mcp/test/unit/esBackend.test.ts b/packages/esmole-mcp/test/unit/esBackend.test.ts new file mode 100644 index 0000000..cb96b91 --- /dev/null +++ b/packages/esmole-mcp/test/unit/esBackend.test.ts @@ -0,0 +1,59 @@ +import type { BackendTarget } from '@dbmole/core' +import { describe, expect, it } from 'vitest' +import type { EsBackend, EsResponse } from '../../src/es/backend.js' +import { createBackend, ping, resolvePort } from '../../src/esBackend.js' +import type { ConnectionConfig } from '../../src/schema.js' + +const config = (extra: Partial = {}): ConnectionConfig => ({ + name: 'es', + type: 'elasticsearch', + host: 'es.example.com', + scheme: 'https', + verifyTls: true, + apiKey: 'secret==', + readonly: false, + ...extra +}) + +const target = (cfg: ConnectionConfig): BackendTarget => ({ + config: cfg, + connectHost: '127.0.0.1', + connectPort: cfg.port ?? 9200, + serverName: cfg.host +}) + +const fakeBackend = (body: unknown): EsBackend => ({ + request: async (): Promise => ({ status: 200, body }), + dispose: async () => {} +}) + +describe('resolvePort', () => { + it('honours an explicit port', () => { + expect(resolvePort(config({ port: 9201 }))).toBe(9201) + }) + + it('defaults to 9200 when absent', () => { + expect(resolvePort(config())).toBe(9200) + }) +}) + +describe('createBackend', () => { + it('returns a backend with request/dispose and no network on construction', async () => { + const backend = createBackend(target(config())) + expect(typeof backend.request).toBe('function') + expect(typeof backend.dispose).toBe('function') + await backend.dispose() + }) +}) + +describe('ping', () => { + it('reports the ES version when present', async () => { + const res = await ping({ backend: fakeBackend({ version: { number: '8.15.0' } }) }) + expect(res).toEqual({ status: 200, version: '8.15.0' }) + }) + + it('reports null when the version is absent', async () => { + const res = await ping({ backend: fakeBackend({}) }) + expect(res).toEqual({ status: 200, version: null }) + }) +}) diff --git a/packages/esmole-mcp/test/unit/manager.test.ts b/packages/esmole-mcp/test/unit/manager.test.ts new file mode 100644 index 0000000..0d8ae36 --- /dev/null +++ b/packages/esmole-mcp/test/unit/manager.test.ts @@ -0,0 +1,29 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createManager } from '../../src/manager.js' +import { createRegistry } from '../../src/registry.js' + +describe('createManager', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'esmole-manager-')) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('builds a manager exposing get/invalidate/disposeAll', async () => { + const registry = createRegistry({ storePath: join(dir, 'connections.json'), env: {} }) + const manager = createManager(registry) + expect(typeof manager.get).toBe('function') + expect(typeof manager.invalidate).toBe('function') + expect(typeof manager.disposeAll).toBe('function') + await expect(manager.disposeAll()).resolves.toBeUndefined() + }) +}) diff --git a/packages/esmole-mcp/test/unit/registry.test.ts b/packages/esmole-mcp/test/unit/registry.test.ts new file mode 100644 index 0000000..04b427c --- /dev/null +++ b/packages/esmole-mcp/test/unit/registry.test.ts @@ -0,0 +1,64 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createRegistry } from '../../src/registry.js' +import type { ConnectionConfig } from '../../src/schema.js' +import { readStore } from '../../src/store.js' + +const conn = (name: string, extra: Partial = {}): ConnectionConfig => ({ + name, + type: 'elasticsearch', + host: 'es.example.com', + scheme: 'https', + verifyTls: true, + apiKey: 'secret==', + readonly: false, + ...extra +}) + +describe('createRegistry', () => { + let dir: string + let storePath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'esmole-registry-')) + storePath = join(dir, 'connections.json') + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('adds, lists, resolves and persists an apiKey connection', () => { + const registry = createRegistry({ storePath, env: {} }) + const added = registry.add(conn('primary')) + expect(added.config.name).toBe('primary') + expect(added.source).toBe('store') + + expect(registry.list().map((r) => r.config.name)).toEqual(['primary']) + expect(registry.resolve('primary').config.apiKey).toBe('secret==') + + // store delegation: written file round-trips through readStore + expect(readStore(storePath).map((c) => c.name)).toEqual(['primary']) + }) + + it('adds a user+password connection', () => { + const registry = createRegistry({ storePath, env: {} }) + registry.add(conn('basic', { apiKey: undefined, user: 'elastic', password: 'pw' })) + const resolved = registry.resolve('basic') + expect(resolved.config.user).toBe('elastic') + expect(resolved.config.password).toBe('pw') + }) + + it('merges env connections over the store (sources delegation)', () => { + const registry = createRegistry({ + storePath, + env: { ESMOLE_CONNECTIONS: JSON.stringify([conn('envc', { host: 'from-env' })]) } + }) + expect(registry.resolve('envc').config.host).toBe('from-env') + expect(registry.resolve('envc').source).toBe('env') + }) +}) diff --git a/packages/esmole-mcp/test/unit/store.test.ts b/packages/esmole-mcp/test/unit/store.test.ts new file mode 100644 index 0000000..ab14de1 --- /dev/null +++ b/packages/esmole-mcp/test/unit/store.test.ts @@ -0,0 +1,55 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConnectionConfig } from '../../src/schema.js' +import { defaultStorePath, readStore, writeStore } from '../../src/store.js' + +const conn = (name: string, extra: Partial = {}): ConnectionConfig => ({ + name, + type: 'elasticsearch', + host: 'es.example.com', + scheme: 'https', + verifyTls: true, + apiKey: 'secret==', + readonly: false, + ...extra +}) + +describe('defaultStorePath', () => { + it('honours ESMOLE_STORE when set', () => { + expect(defaultStorePath({ ESMOLE_STORE: '/custom/store.json' })).toBe('/custom/store.json') + }) + + it('falls back to the esmole config dir', () => { + const p = defaultStorePath({}) + expect(p.endsWith(join('.config', 'esmole', 'connections.json'))).toBe(true) + }) +}) + +describe('readStore / writeStore', () => { + let dir: string + let storePath: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'esmole-store-')) + storePath = join(dir, 'connections.json') + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('round-trips a connection through the core store', () => { + writeStore(storePath, [conn('es1')]) + const read = readStore(storePath) + expect(read).toHaveLength(1) + expect(read[0]).toMatchObject({ name: 'es1', type: 'elasticsearch', apiKey: 'secret==' }) + }) + + it('returns an empty list for a missing store file', () => { + expect(readStore(join(dir, 'nope.json'))).toEqual([]) + }) +})