diff --git a/packages/core/src/mcp/connectionTools.ts b/packages/core/src/mcp/connectionTools.ts index a746a37..7f5e9f9 100644 --- a/packages/core/src/mcp/connectionTools.ts +++ b/packages/core/src/mcp/connectionTools.ts @@ -10,7 +10,17 @@ import { errorMessage, fail, ok } from './respond.js' export type ConnectionToolsDeps = { registry: Registry manager: Manager - fullSchema: z.ZodType + // Advertised as add_connection's inputSchema AND used to parse the call. + // Typed as a `ZodObject` (NOT a bare `ZodType`) on purpose: the SDK can only + // emit JSON Schema properties for a schema that exposes `.shape`. A + // `.superRefine`/`ZodEffects` wrapper has no `.shape`, so it would publish an + // empty `{ properties: {} }` — the type now rejects that at compile time. + // Put cross-field refinements on `parseSchema`, not here. + fullSchema: z.ZodObject + // Optional stricter validator (e.g. cross-field `.superRefine`) applied in the + // handler after the wire-level parse. Keeps refinements that can't live on the + // advertised object schema without breaking its serialization. + parseSchema?: z.ZodType patchSchema: z.ZodTypeAny publicView: (resolved: ResolvedConnection) => unknown ping: (managed: ManagedConnection) => Promise> @@ -38,7 +48,8 @@ export const registerConnectionTools = { try { - return ok({ added: deps.publicView(registry.add(deps.fullSchema.parse(config))) }) + const parsed = (deps.parseSchema ?? deps.fullSchema).parse(config) + return ok({ added: deps.publicView(registry.add(parsed)) }) } catch (error) { return fail(errorMessage(error)) } diff --git a/packages/esmole-mcp/src/schema.ts b/packages/esmole-mcp/src/schema.ts index 8344ec9..770a720 100644 --- a/packages/esmole-mcp/src/schema.ts +++ b/packages/esmole-mcp/src/schema.ts @@ -1,7 +1,12 @@ import { baseConnectionShape, type ResolvedConnection, sshConfigSchema } from '@dbmole/core' import * as z from 'zod' -export const connectionConfigSchema = z +// Plain strict object: kept separate from the refined schema below so the MCP +// SDK can serialize it to a JSON Schema with real properties. A `.superRefine` +// wrapper is a ZodEffects with no `.shape`, which the SDK can't introspect and +// would publish as an empty `{ properties: {} }`. This is add_connection's wire +// schema; the cross-field check lives on connectionConfigSchema. +export const connectionObjectSchema = z .object({ ...baseConnectionShape, user: z.string().min(1).optional(), @@ -11,14 +16,15 @@ export const connectionConfigSchema = z apiKey: z.string().min(1).optional() }) .strict() - .superRefine((c, ctx) => { - if (c.apiKey === undefined && !(c.user !== undefined && c.password !== undefined)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'provide either apiKey or both user and password' - }) - } - }) + +export const connectionConfigSchema = connectionObjectSchema.superRefine((c, ctx) => { + if (c.apiKey === undefined && !(c.user !== undefined && c.password !== undefined)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'provide either apiKey or both user and password' + }) + } +}) export type ConnectionConfig = z.infer export type EsResolvedConnection = ResolvedConnection diff --git a/packages/esmole-mcp/src/server.ts b/packages/esmole-mcp/src/server.ts index 45d06d6..9d49a6a 100644 --- a/packages/esmole-mcp/src/server.ts +++ b/packages/esmole-mcp/src/server.ts @@ -9,6 +9,7 @@ import type { EsRegistry } from './registry.js' import { type ConnectionConfig, connectionConfigSchema, + connectionObjectSchema, defaultPort, type EsResolvedConnection, sshConfigSchema @@ -53,7 +54,8 @@ export const createServer = (registry: EsRegistry, manager: EsManager): McpServe registerConnectionTools(server, { registry, manager, - fullSchema: connectionConfigSchema, + fullSchema: connectionObjectSchema, + parseSchema: connectionConfigSchema, patchSchema, publicView, formatError: esFormatError, diff --git a/packages/esmole-mcp/test/unit/server.test.ts b/packages/esmole-mcp/test/unit/server.test.ts index 40f714a..e4fa3c3 100644 --- a/packages/esmole-mcp/test/unit/server.test.ts +++ b/packages/esmole-mcp/test/unit/server.test.ts @@ -53,6 +53,69 @@ describe('createServer', () => { 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({