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 })