/ src / lib / server / mcp-client.test.ts
mcp-client.test.ts
  1  import { describe, it } from 'node:test'
  2  import assert from 'node:assert/strict'
  3  import { z } from 'zod'
  4  import {
  5    jsonSchemaToZod,
  6    sanitizeName,
  7    mcpToolsToLangChain,
  8    disconnectMcpServer,
  9    connectMcpServer,
 10  } from './mcp-client'
 11  
 12  /* ============================================================
 13   * 1. sanitizeName
 14   * ============================================================ */
 15  
 16  describe('sanitizeName', () => {
 17    it('strips spaces, hyphens, dots → underscores', () => {
 18      assert.equal(sanitizeName('my-server.name here'), 'my_server_name_here')
 19    })
 20  
 21    it('preserves alphanumeric and underscores', () => {
 22      assert.equal(sanitizeName('abc_123_XYZ'), 'abc_123_XYZ')
 23    })
 24  
 25    it('empty string stays empty', () => {
 26      assert.equal(sanitizeName(''), '')
 27    })
 28  
 29    it('replaces all special characters', () => {
 30      assert.equal(sanitizeName('a@b#c$d%e'), 'a_b_c_d_e')
 31    })
 32  })
 33  
 34  /* ============================================================
 35   * 2. jsonSchemaToZod
 36   * ============================================================ */
 37  
 38  describe('jsonSchemaToZod', () => {
 39    it('empty/null schema returns empty z.object', () => {
 40      const s1 = jsonSchemaToZod(null)
 41      assert.deepEqual(s1.parse({}), {})
 42  
 43      const s2 = jsonSchemaToZod(undefined)
 44      assert.deepEqual(s2.parse({}), {})
 45  
 46      const s3 = jsonSchemaToZod({})
 47      assert.deepEqual(s3.parse({}), {})
 48    })
 49  
 50    it('simple string property', () => {
 51      const schema = jsonSchemaToZod({
 52        type: 'object',
 53        properties: { name: { type: 'string' } },
 54        required: ['name'],
 55      })
 56      assert.deepEqual(schema.parse({ name: 'hello' }), { name: 'hello' })
 57  
 58      const bad = schema.safeParse({ name: 123 })
 59      assert.equal(bad.success, false)
 60    })
 61  
 62    it('simple number property', () => {
 63      const schema = jsonSchemaToZod({
 64        type: 'object',
 65        properties: { count: { type: 'number' } },
 66        required: ['count'],
 67      })
 68      assert.deepEqual(schema.parse({ count: 42 }), { count: 42 })
 69    })
 70  
 71    it('integer maps to z.number()', () => {
 72      const schema = jsonSchemaToZod({
 73        type: 'object',
 74        properties: { age: { type: 'integer' } },
 75        required: ['age'],
 76      })
 77      assert.deepEqual(schema.parse({ age: 25 }), { age: 25 })
 78    })
 79  
 80    it('boolean property', () => {
 81      const schema = jsonSchemaToZod({
 82        type: 'object',
 83        properties: { active: { type: 'boolean' } },
 84        required: ['active'],
 85      })
 86      assert.deepEqual(schema.parse({ active: true }), { active: true })
 87    })
 88  
 89    it('required vs optional properties', () => {
 90      const schema = jsonSchemaToZod({
 91        type: 'object',
 92        properties: {
 93          name: { type: 'string' },
 94          age: { type: 'number' },
 95        },
 96        required: ['name'],
 97      })
 98  
 99      // name required, age optional
100      assert.deepEqual(schema.parse({ name: 'Alice' }), { name: 'Alice' })
101  
102      const bad = schema.safeParse({})
103      assert.equal(bad.success, false)
104  
105      // with both fields
106      assert.deepEqual(schema.parse({ name: 'Alice', age: 30 }), { name: 'Alice', age: 30 })
107    })
108  
109    it('nested object schema', () => {
110      const schema = jsonSchemaToZod({
111        type: 'object',
112        properties: {
113          address: {
114            type: 'object',
115            properties: {
116              city: { type: 'string' },
117              zip: { type: 'string' },
118            },
119            required: ['city'],
120          },
121        },
122        required: ['address'],
123      })
124      const result = schema.parse({ address: { city: 'NYC' } })
125      assert.deepEqual(result, { address: { city: 'NYC' } })
126    })
127  
128    it('array properties', () => {
129      const schema = jsonSchemaToZod({
130        type: 'object',
131        properties: {
132          tags: { type: 'array' },
133        },
134        required: ['tags'],
135      })
136      assert.deepEqual(schema.parse({ tags: ['a', 'b'] }), { tags: ['a', 'b'] })
137    })
138  
139    it('unknown type falls through to z.any()', () => {
140      const schema = jsonSchemaToZod({
141        type: 'object',
142        properties: {
143          data: { type: 'fancyType' },
144        },
145        required: ['data'],
146      })
147      // z.any() accepts anything
148      assert.deepEqual(schema.parse({ data: 'whatever' }), { data: 'whatever' })
149      assert.deepEqual(schema.parse({ data: 42 }), { data: 42 })
150      assert.deepEqual(schema.parse({ data: null }), { data: null })
151    })
152  
153    it('schema with required array properly marks fields', () => {
154      const schema = jsonSchemaToZod({
155        type: 'object',
156        properties: {
157          a: { type: 'string' },
158          b: { type: 'string' },
159          c: { type: 'string' },
160        },
161        required: ['a', 'c'],
162      })
163      // a and c required, b optional
164      const result = schema.parse({ a: 'x', c: 'z' })
165      assert.deepEqual(result, { a: 'x', c: 'z' })
166  
167      const bad = schema.safeParse({ a: 'x' }) // missing c
168      assert.equal(bad.success, false)
169    })
170  
171    it('no properties key returns empty object schema', () => {
172      const schema = jsonSchemaToZod({ type: 'object' })
173      assert.deepEqual(schema.parse({}), {})
174    })
175  
176    it('prop with no type returns z.any()', () => {
177      const schema = jsonSchemaToZod({
178        type: 'object',
179        properties: {
180          mystery: {},
181        },
182        required: ['mystery'],
183      })
184      assert.deepEqual(schema.parse({ mystery: 'anything' }), { mystery: 'anything' })
185    })
186  
187    it('string enums map to a constrained schema', () => {
188      const schema = jsonSchemaToZod({
189        type: 'object',
190        properties: {
191          action: {
192            type: 'string',
193            enum: ['send', 'send_voice_note', 'schedule_followup'],
194          },
195        },
196        required: ['action'],
197      })
198  
199      assert.deepEqual(schema.parse({ action: 'send_voice_note' }), { action: 'send_voice_note' })
200      assert.equal(schema.safeParse({ action: 'bogus' }).success, false)
201    })
202  })
203  
204  /* ============================================================
205   * 3. mcpToolsToLangChain
206   * ============================================================ */
207  
208  describe('mcpToolsToLangChain', () => {
209    function makeMockClient(tools: any[], callToolResult?: any) {
210      return {
211        listTools: async () => ({ tools }),
212        callTool: async (args: any) => callToolResult ?? { content: [] },
213      }
214    }
215  
216    it('prefixes tool names with mcp_{sanitized_server}_{tool_name}', async () => {
217      const client = makeMockClient([
218        { name: 'read_file', description: 'Reads a file', inputSchema: { type: 'object', properties: {} } },
219      ])
220      const tools = await mcpToolsToLangChain(client, 'my-server')
221      assert.equal(tools.length, 1)
222      assert.equal(tools[0].name, 'mcp_my_server_read_file')
223    })
224  
225    it('passes tool descriptions through', async () => {
226      const client = makeMockClient([
227        { name: 'tool1', description: 'My cool tool', inputSchema: { type: 'object', properties: {} } },
228      ])
229      const tools = await mcpToolsToLangChain(client, 'srv')
230      assert.equal(tools[0].description, 'My cool tool')
231    })
232  
233    it('uses default description when none provided', async () => {
234      const client = makeMockClient([
235        { name: 'tool1', inputSchema: { type: 'object', properties: {} } },
236      ])
237      const tools = await mcpToolsToLangChain(client, 'srv')
238      assert.equal(tools[0].description, 'MCP tool: tool1')
239    })
240  
241    it('tool execution calls client.callTool with correct args', async () => {
242      let captured: any = null
243      const client = {
244        listTools: async () => ({
245          tools: [
246            { name: 'greet', description: 'Greets', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } },
247          ],
248        }),
249        callTool: async (args: any) => {
250          captured = args
251          return { content: [{ type: 'text', text: 'Hello!' }] }
252        },
253      }
254      const tools = await mcpToolsToLangChain(client, 'test')
255      const result = await tools[0].invoke({ name: 'World' })
256      assert.deepEqual(captured, { name: 'greet', arguments: { name: 'World' } })
257    })
258  
259    it('joins text content parts', async () => {
260      const client = makeMockClient(
261        [{ name: 't', description: 'd', inputSchema: { type: 'object', properties: {} } }],
262        { content: [{ type: 'text', text: 'line1' }, { type: 'text', text: 'line2' }, { type: 'image', data: 'x' }] }
263      )
264      const tools = await mcpToolsToLangChain(client, 'srv')
265      const result = await tools[0].invoke({})
266      assert.equal(result, 'line1\nline2')
267    })
268  
269    it('returns (no output) when no text content', async () => {
270      const client = makeMockClient(
271        [{ name: 't', description: 'd', inputSchema: { type: 'object', properties: {} } }],
272        { content: [{ type: 'image', data: 'x' }] }
273      )
274      const tools = await mcpToolsToLangChain(client, 'srv')
275      const result = await tools[0].invoke({})
276      assert.equal(result, '(no output)')
277    })
278  
279    it('returns (no output) when content is empty', async () => {
280      const client = makeMockClient(
281        [{ name: 't', description: 'd', inputSchema: { type: 'object', properties: {} } }],
282        { content: [] }
283      )
284      const tools = await mcpToolsToLangChain(client, 'srv')
285      const result = await tools[0].invoke({})
286      assert.equal(result, '(no output)')
287    })
288  
289    it('returns (no output) when content is undefined', async () => {
290      const client = makeMockClient(
291        [{ name: 't', description: 'd', inputSchema: { type: 'object', properties: {} } }],
292        {}
293      )
294      const tools = await mcpToolsToLangChain(client, 'srv')
295      const result = await tools[0].invoke({})
296      assert.equal(result, '(no output)')
297    })
298  
299    it('handles multiple tools', async () => {
300      const client = makeMockClient([
301        { name: 'a', description: 'Tool A', inputSchema: { type: 'object', properties: {} } },
302        { name: 'b', description: 'Tool B', inputSchema: { type: 'object', properties: {} } },
303        { name: 'c', description: 'Tool C', inputSchema: { type: 'object', properties: {} } },
304      ])
305      const tools = await mcpToolsToLangChain(client, 'x')
306      assert.equal(tools.length, 3)
307      assert.equal(tools[0].name, 'mcp_x_a')
308      assert.equal(tools[1].name, 'mcp_x_b')
309      assert.equal(tools[2].name, 'mcp_x_c')
310    })
311  })
312  
313  /* ============================================================
314   * 4. disconnectMcpServer
315   * ============================================================ */
316  
317  describe('disconnectMcpServer', () => {
318    it('calls close on both client and transport', async () => {
319      let clientClosed = false
320      let transportClosed = false
321      const client = { close: async () => { clientClosed = true } }
322      const transport = { close: async () => { transportClosed = true } }
323      await disconnectMcpServer(client, transport)
324      assert.equal(clientClosed, true)
325      assert.equal(transportClosed, true)
326    })
327  
328    it('does not throw if client.close fails', async () => {
329      const client = { close: async () => { throw new Error('boom') } }
330      const transport = { close: async () => {} }
331      await assert.doesNotReject(() => disconnectMcpServer(client, transport))
332    })
333  
334    it('does not throw if transport.close fails', async () => {
335      const client = { close: async () => {} }
336      const transport = { close: async () => { throw new Error('boom') } }
337      await assert.doesNotReject(() => disconnectMcpServer(client, transport))
338    })
339  
340    it('does not throw if both close fail', async () => {
341      const client = { close: async () => { throw new Error('a') } }
342      const transport = { close: async () => { throw new Error('b') } }
343      await assert.doesNotReject(() => disconnectMcpServer(client, transport))
344    })
345  })
346  
347  /* ============================================================
348   * 5. connectMcpServer
349   * ============================================================ */
350  
351  describe('connectMcpServer', () => {
352    it('throws on unsupported transport type', async () => {
353      await assert.rejects(
354        () => connectMcpServer({ transport: 'websocket' as any, name: 'test', id: 'x', createdAt: 0, updatedAt: 0 }),
355        { message: 'Unsupported MCP transport: websocket' }
356      )
357    })
358  })