opencode-provider-litellm 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/mcp-tools.test.ts +570 -0
- package/src/mcp-tools.ts +253 -0
- package/src/plugin.test.ts +135 -0
- package/src/plugin.ts +24 -0
- package/src/skills.test.ts +449 -0
- package/src/skills.ts +240 -0
- package/src/types.ts +15 -0
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-provider-litellm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "OpenCode plugin for any LiteLLM proxy — auto-discovers models, auth, and capabilities",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
|
-
".": "./src/index.ts"
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./server": "./src/index.ts"
|
|
8
9
|
},
|
|
9
10
|
"files": [
|
|
10
11
|
"src"
|
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { tool } from '@opencode-ai/plugin'
|
|
3
|
+
import type { PluginConfig, McpTool } from './types.js'
|
|
4
|
+
import { discoverMcpTools, executeMcpTool, createMcpToolDefinitions } from './mcp-tools.js'
|
|
5
|
+
|
|
6
|
+
describe('discoverMcpTools', () => {
|
|
7
|
+
const config: PluginConfig = {
|
|
8
|
+
url: 'https://litellm.example.com',
|
|
9
|
+
apiKey: 'test-api-key',
|
|
10
|
+
}
|
|
11
|
+
const token = 'test-token'
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.restoreAllMocks()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('returns tools from a mock response', async () => {
|
|
18
|
+
const mockTools: McpTool[] = [
|
|
19
|
+
{
|
|
20
|
+
name: 'search',
|
|
21
|
+
server_name: 'brave',
|
|
22
|
+
description: 'Search the web',
|
|
23
|
+
input_schema: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
query: { type: 'string' },
|
|
27
|
+
},
|
|
28
|
+
required: ['query'],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'fetch',
|
|
33
|
+
server_name: 'fetch',
|
|
34
|
+
description: 'Fetch a URL',
|
|
35
|
+
input_schema: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
url: { type: 'string' },
|
|
39
|
+
},
|
|
40
|
+
required: ['url'],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
46
|
+
ok: true,
|
|
47
|
+
status: 200,
|
|
48
|
+
json: async () => mockTools,
|
|
49
|
+
})
|
|
50
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
51
|
+
|
|
52
|
+
const result = await discoverMcpTools(config, token)
|
|
53
|
+
|
|
54
|
+
expect(result).toEqual(mockTools)
|
|
55
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
56
|
+
'https://litellm.example.com/mcp-rest/tools/list',
|
|
57
|
+
expect.objectContaining({
|
|
58
|
+
method: 'GET',
|
|
59
|
+
headers: expect.objectContaining({
|
|
60
|
+
Authorization: 'Bearer test-token',
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
}),
|
|
63
|
+
signal: expect.any(AbortSignal),
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns [] on network error', async () => {
|
|
69
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error('network error'))
|
|
70
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
71
|
+
|
|
72
|
+
const result = await discoverMcpTools(config, token)
|
|
73
|
+
expect(result).toEqual([])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('returns [] on 4xx response', async () => {
|
|
77
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
78
|
+
ok: false,
|
|
79
|
+
status: 404,
|
|
80
|
+
})
|
|
81
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
82
|
+
|
|
83
|
+
const result = await discoverMcpTools(config, token)
|
|
84
|
+
expect(result).toEqual([])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('returns [] on 5xx response', async () => {
|
|
88
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
89
|
+
ok: false,
|
|
90
|
+
status: 500,
|
|
91
|
+
})
|
|
92
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
93
|
+
|
|
94
|
+
const result = await discoverMcpTools(config, token)
|
|
95
|
+
expect(result).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('returns [] when response is not an array', async () => {
|
|
99
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
100
|
+
ok: true,
|
|
101
|
+
status: 200,
|
|
102
|
+
json: async () => ({ tools: [] }),
|
|
103
|
+
})
|
|
104
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
105
|
+
|
|
106
|
+
const result = await discoverMcpTools(config, token)
|
|
107
|
+
expect(result).toEqual([])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('returns [] when response is empty array', async () => {
|
|
111
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
112
|
+
ok: true,
|
|
113
|
+
status: 200,
|
|
114
|
+
json: async () => [],
|
|
115
|
+
})
|
|
116
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
117
|
+
|
|
118
|
+
const result = await discoverMcpTools(config, token)
|
|
119
|
+
expect(result).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('respects timeout (AbortError after 10s)', async () => {
|
|
123
|
+
vi.useFakeTimers()
|
|
124
|
+
|
|
125
|
+
const mockFetch = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
|
|
126
|
+
return new Promise<Response>((_resolve, reject) => {
|
|
127
|
+
const signal = init?.signal
|
|
128
|
+
if (signal) {
|
|
129
|
+
signal.addEventListener('abort', () => {
|
|
130
|
+
reject(new DOMException('The operation was aborted.', 'AbortError'))
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
136
|
+
|
|
137
|
+
const promise = discoverMcpTools(config, token)
|
|
138
|
+
|
|
139
|
+
await vi.advanceTimersByTimeAsync(10001)
|
|
140
|
+
|
|
141
|
+
const result = await promise
|
|
142
|
+
|
|
143
|
+
vi.useRealTimers()
|
|
144
|
+
|
|
145
|
+
expect(result).toEqual([])
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('executeMcpTool', () => {
|
|
150
|
+
const config: PluginConfig = {
|
|
151
|
+
url: 'https://litellm.example.com',
|
|
152
|
+
apiKey: 'test-api-key',
|
|
153
|
+
}
|
|
154
|
+
const token = 'test-token'
|
|
155
|
+
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
vi.restoreAllMocks()
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('returns formatted result on success', async () => {
|
|
161
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
162
|
+
ok: true,
|
|
163
|
+
status: 200,
|
|
164
|
+
json: async () => ({
|
|
165
|
+
result: { content: [{ type: 'text', text: 'Search results found' }] },
|
|
166
|
+
}),
|
|
167
|
+
})
|
|
168
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
169
|
+
|
|
170
|
+
const result = await executeMcpTool(
|
|
171
|
+
config,
|
|
172
|
+
token,
|
|
173
|
+
'brave',
|
|
174
|
+
'search',
|
|
175
|
+
{ query: 'test' },
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
expect(result).toBe(JSON.stringify({ content: [{ type: 'text', text: 'Search results found' }] }, null, 2))
|
|
179
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
180
|
+
'https://litellm.example.com/mcp-rest/tools/call',
|
|
181
|
+
expect.objectContaining({
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: expect.objectContaining({
|
|
184
|
+
Authorization: 'Bearer test-token',
|
|
185
|
+
'Content-Type': 'application/json',
|
|
186
|
+
}),
|
|
187
|
+
body: JSON.stringify({ server: 'brave', tool: 'search', args: { query: 'test' } }),
|
|
188
|
+
signal: expect.any(AbortSignal),
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('stringifies entire response when no result field', async () => {
|
|
194
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
195
|
+
ok: true,
|
|
196
|
+
status: 200,
|
|
197
|
+
json: async () => ({
|
|
198
|
+
output: [{ type: 'text', text: 'plain text' }],
|
|
199
|
+
}),
|
|
200
|
+
})
|
|
201
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
202
|
+
|
|
203
|
+
const result = await executeMcpTool(
|
|
204
|
+
config,
|
|
205
|
+
token,
|
|
206
|
+
'brave',
|
|
207
|
+
'search',
|
|
208
|
+
{ query: 'test' },
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
expect(result).toBe(JSON.stringify({ output: [{ type: 'text', text: 'plain text' }] }, null, 2))
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('returns error string on failure', async () => {
|
|
215
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error('connection refused'))
|
|
216
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
217
|
+
|
|
218
|
+
const result = await executeMcpTool(
|
|
219
|
+
config,
|
|
220
|
+
token,
|
|
221
|
+
'brave',
|
|
222
|
+
'search',
|
|
223
|
+
{ query: 'test' },
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
expect(result).toBe('Error calling search on brave: connection refused')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('returns error string on non-ok response', async () => {
|
|
230
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
231
|
+
ok: false,
|
|
232
|
+
status: 500,
|
|
233
|
+
})
|
|
234
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
235
|
+
|
|
236
|
+
const result = await executeMcpTool(
|
|
237
|
+
config,
|
|
238
|
+
token,
|
|
239
|
+
'myserver',
|
|
240
|
+
'mytool',
|
|
241
|
+
{ arg1: 'val1' },
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
expect(result).toContain('Error calling mytool on myserver')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('respects timeout (AbortError after 30s)', async () => {
|
|
248
|
+
vi.useFakeTimers()
|
|
249
|
+
|
|
250
|
+
const mockFetch = vi.fn().mockImplementation((_url: string, init: RequestInit) => {
|
|
251
|
+
return new Promise<string>((_resolve, reject) => {
|
|
252
|
+
const signal = init?.signal
|
|
253
|
+
if (signal) {
|
|
254
|
+
signal.addEventListener('abort', () => {
|
|
255
|
+
reject(new DOMException('The operation was aborted.', 'AbortError'))
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
261
|
+
|
|
262
|
+
const promise = executeMcpTool(
|
|
263
|
+
config,
|
|
264
|
+
token,
|
|
265
|
+
'brave',
|
|
266
|
+
'search',
|
|
267
|
+
{ query: 'test' },
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
await vi.advanceTimersByTimeAsync(30001)
|
|
271
|
+
|
|
272
|
+
const result = await promise
|
|
273
|
+
|
|
274
|
+
vi.useRealTimers()
|
|
275
|
+
|
|
276
|
+
expect(result).toContain('Error calling search on brave')
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
describe('createMcpToolDefinitions', () => {
|
|
281
|
+
const config: PluginConfig = {
|
|
282
|
+
url: 'https://litellm.example.com',
|
|
283
|
+
apiKey: 'test-api-key',
|
|
284
|
+
}
|
|
285
|
+
const token = 'test-token'
|
|
286
|
+
|
|
287
|
+
beforeEach(() => {
|
|
288
|
+
vi.restoreAllMocks()
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('produces correct tool names (namespaced, sanitized)', async () => {
|
|
292
|
+
const mockTools: McpTool[] = [
|
|
293
|
+
{
|
|
294
|
+
name: 'web-search',
|
|
295
|
+
server_name: 'Brave-API',
|
|
296
|
+
description: 'Search the web',
|
|
297
|
+
input_schema: {
|
|
298
|
+
type: 'object',
|
|
299
|
+
properties: {
|
|
300
|
+
query: { type: 'string' },
|
|
301
|
+
},
|
|
302
|
+
required: ['query'],
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
308
|
+
ok: true,
|
|
309
|
+
status: 200,
|
|
310
|
+
json: async () => mockTools,
|
|
311
|
+
})
|
|
312
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
313
|
+
|
|
314
|
+
const result = await createMcpToolDefinitions(config, token)
|
|
315
|
+
|
|
316
|
+
expect(Object.keys(result)).toEqual(['mcp_brave_api_web_search'])
|
|
317
|
+
const toolDef = result['mcp_brave_api_web_search']
|
|
318
|
+
expect(toolDef.description).toBe('Search the web (via Brave-API MCP server)')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('returns {} when no tools discovered', async () => {
|
|
322
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
323
|
+
ok: true,
|
|
324
|
+
status: 200,
|
|
325
|
+
json: async () => [],
|
|
326
|
+
})
|
|
327
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
328
|
+
|
|
329
|
+
const result = await createMcpToolDefinitions(config, token)
|
|
330
|
+
expect(result).toEqual({})
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('maps JSON Schema types correctly', async () => {
|
|
334
|
+
const mockTools: McpTool[] = [
|
|
335
|
+
{
|
|
336
|
+
name: 'test_tool',
|
|
337
|
+
server_name: 'test_server',
|
|
338
|
+
description: 'Test tool',
|
|
339
|
+
input_schema: {
|
|
340
|
+
type: 'object',
|
|
341
|
+
properties: {
|
|
342
|
+
name: { type: 'string' },
|
|
343
|
+
count: { type: 'number' },
|
|
344
|
+
is_active: { type: 'boolean' },
|
|
345
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
346
|
+
optional_val: { type: 'string' },
|
|
347
|
+
},
|
|
348
|
+
required: ['name', 'count', 'is_active', 'tags'],
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
354
|
+
ok: true,
|
|
355
|
+
status: 200,
|
|
356
|
+
json: async () => mockTools,
|
|
357
|
+
})
|
|
358
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
359
|
+
|
|
360
|
+
const result = await createMcpToolDefinitions(config, token)
|
|
361
|
+
|
|
362
|
+
const toolDef = result['mcp_test_server_test_tool']
|
|
363
|
+
expect(toolDef).toBeDefined()
|
|
364
|
+
|
|
365
|
+
// Check that args were built correctly
|
|
366
|
+
const args = toolDef.args
|
|
367
|
+
expect(args).toBeDefined()
|
|
368
|
+
|
|
369
|
+
const z = tool.schema
|
|
370
|
+
|
|
371
|
+
// Required string field
|
|
372
|
+
expect(args.name).toBeInstanceOf(z.ZodString)
|
|
373
|
+
expect(args.name.isOptional()).toBe(false)
|
|
374
|
+
|
|
375
|
+
// Required number field
|
|
376
|
+
expect(args.count).toBeInstanceOf(z.ZodNumber)
|
|
377
|
+
expect(args.count.isOptional()).toBe(false)
|
|
378
|
+
|
|
379
|
+
// Required boolean field
|
|
380
|
+
expect(args.is_active).toBeInstanceOf(z.ZodBoolean)
|
|
381
|
+
expect(args.is_active.isOptional()).toBe(false)
|
|
382
|
+
|
|
383
|
+
// Required array field
|
|
384
|
+
expect(args.tags).toBeInstanceOf(z.ZodArray)
|
|
385
|
+
expect(args.tags.isOptional()).toBe(false)
|
|
386
|
+
|
|
387
|
+
// Optional field
|
|
388
|
+
expect(args.optional_val.isOptional()).toBe(true)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('falls back to single-arg mode for unmappable schemas', async () => {
|
|
392
|
+
const mockTools: McpTool[] = [
|
|
393
|
+
{
|
|
394
|
+
name: 'complex_tool',
|
|
395
|
+
server_name: 'complex_server',
|
|
396
|
+
description: 'Complex tool with nested schema',
|
|
397
|
+
input_schema: {
|
|
398
|
+
type: 'object',
|
|
399
|
+
properties: {
|
|
400
|
+
nested: {
|
|
401
|
+
type: 'object',
|
|
402
|
+
properties: {
|
|
403
|
+
deep: { type: 'string' },
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
required: ['nested'],
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
]
|
|
411
|
+
|
|
412
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
413
|
+
ok: true,
|
|
414
|
+
status: 200,
|
|
415
|
+
json: async () => mockTools,
|
|
416
|
+
})
|
|
417
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
418
|
+
|
|
419
|
+
const result = await createMcpToolDefinitions(config, token)
|
|
420
|
+
|
|
421
|
+
const toolDef = result['mcp_complex_server_complex_tool']
|
|
422
|
+
expect(toolDef).toBeDefined()
|
|
423
|
+
|
|
424
|
+
// Should fall back to single-arg mode
|
|
425
|
+
const args = toolDef.args
|
|
426
|
+
expect(args.args).toBeDefined()
|
|
427
|
+
expect(args.args).toBeInstanceOf(tool.schema.ZodRecord)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('falls back to single-arg mode for $ref schemas', async () => {
|
|
431
|
+
const mockTools: McpTool[] = [
|
|
432
|
+
{
|
|
433
|
+
name: 'ref_tool',
|
|
434
|
+
server_name: 'ref_server',
|
|
435
|
+
description: 'Tool with $ref',
|
|
436
|
+
input_schema: {
|
|
437
|
+
type: 'object',
|
|
438
|
+
properties: {
|
|
439
|
+
data: { $ref: '#/definitions/Data' },
|
|
440
|
+
},
|
|
441
|
+
required: ['data'],
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
]
|
|
445
|
+
|
|
446
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
447
|
+
ok: true,
|
|
448
|
+
status: 200,
|
|
449
|
+
json: async () => mockTools,
|
|
450
|
+
})
|
|
451
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
452
|
+
|
|
453
|
+
const result = await createMcpToolDefinitions(config, token)
|
|
454
|
+
|
|
455
|
+
const toolDef = result['mcp_ref_server_ref_tool']
|
|
456
|
+
expect(toolDef).toBeDefined()
|
|
457
|
+
|
|
458
|
+
// Should fall back to single-arg mode
|
|
459
|
+
const args = toolDef.args
|
|
460
|
+
expect(args.args).toBeDefined()
|
|
461
|
+
expect(args.args).toBeInstanceOf(tool.schema.ZodRecord)
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
it('falls back to single-arg mode for anyOf schemas', async () => {
|
|
465
|
+
const mockTools: McpTool[] = [
|
|
466
|
+
{
|
|
467
|
+
name: 'anyof_tool',
|
|
468
|
+
server_name: 'anyof_server',
|
|
469
|
+
description: 'Tool with anyOf',
|
|
470
|
+
input_schema: {
|
|
471
|
+
type: 'object',
|
|
472
|
+
properties: {
|
|
473
|
+
value: { anyOf: [{ type: 'string' }, { type: 'number' }] },
|
|
474
|
+
},
|
|
475
|
+
required: ['value'],
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
481
|
+
ok: true,
|
|
482
|
+
status: 200,
|
|
483
|
+
json: async () => mockTools,
|
|
484
|
+
})
|
|
485
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
486
|
+
|
|
487
|
+
const result = await createMcpToolDefinitions(config, token)
|
|
488
|
+
|
|
489
|
+
const toolDef = result['mcp_anyof_server_anyof_tool']
|
|
490
|
+
expect(toolDef).toBeDefined()
|
|
491
|
+
|
|
492
|
+
// Should fall back to single-arg mode
|
|
493
|
+
const args = toolDef.args
|
|
494
|
+
expect(args.args).toBeDefined()
|
|
495
|
+
expect(args.args).toBeInstanceOf(tool.schema.ZodRecord)
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('execute function calls executeMcpTool correctly', async () => {
|
|
499
|
+
let callCount = 0
|
|
500
|
+
const mockFetch = vi.fn().mockImplementation((_url: string, _init: RequestInit) => {
|
|
501
|
+
callCount++
|
|
502
|
+
if (callCount === 1) {
|
|
503
|
+
// First call: discover
|
|
504
|
+
return Promise.resolve({
|
|
505
|
+
ok: true,
|
|
506
|
+
status: 200,
|
|
507
|
+
json: async () => [{
|
|
508
|
+
name: 'search',
|
|
509
|
+
server_name: 'brave',
|
|
510
|
+
description: 'Search the web',
|
|
511
|
+
input_schema: {
|
|
512
|
+
type: 'object',
|
|
513
|
+
properties: { query: { type: 'string' } },
|
|
514
|
+
required: ['query'],
|
|
515
|
+
},
|
|
516
|
+
}],
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
// Second call: execute
|
|
520
|
+
return Promise.resolve({
|
|
521
|
+
ok: true,
|
|
522
|
+
status: 200,
|
|
523
|
+
json: async () => ({ result: { content: [{ type: 'text', text: 'found' }] } }),
|
|
524
|
+
})
|
|
525
|
+
})
|
|
526
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
527
|
+
|
|
528
|
+
const result = await createMcpToolDefinitions(config, token)
|
|
529
|
+
|
|
530
|
+
const toolDef = result['mcp_brave_search']
|
|
531
|
+
expect(toolDef).toBeDefined()
|
|
532
|
+
|
|
533
|
+
// Call execute
|
|
534
|
+
const executeResult = await toolDef.execute({ query: 'test' }, {} as any)
|
|
535
|
+
expect(executeResult).toBe(JSON.stringify({ content: [{ type: 'text', text: 'found' }] }, null, 2))
|
|
536
|
+
expect(callCount).toBe(2)
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('maps integer type to number()', async () => {
|
|
540
|
+
const mockTools: McpTool[] = [
|
|
541
|
+
{
|
|
542
|
+
name: 'int_tool',
|
|
543
|
+
server_name: 'int_server',
|
|
544
|
+
description: 'Tool with integer',
|
|
545
|
+
input_schema: {
|
|
546
|
+
type: 'object',
|
|
547
|
+
properties: {
|
|
548
|
+
page: { type: 'integer' },
|
|
549
|
+
},
|
|
550
|
+
required: ['page'],
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
]
|
|
554
|
+
|
|
555
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
556
|
+
ok: true,
|
|
557
|
+
status: 200,
|
|
558
|
+
json: async () => mockTools,
|
|
559
|
+
})
|
|
560
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
561
|
+
|
|
562
|
+
const result = await createMcpToolDefinitions(config, token)
|
|
563
|
+
|
|
564
|
+
const toolDef = result['mcp_int_server_int_tool']
|
|
565
|
+
expect(toolDef).toBeDefined()
|
|
566
|
+
|
|
567
|
+
const args = toolDef.args
|
|
568
|
+
expect(args.page).toBeInstanceOf(tool.schema.ZodNumber)
|
|
569
|
+
})
|
|
570
|
+
})
|