feat(mcp): add list_vdis tool to list virtual disks (#9559)

This commit is contained in:
Mathieu
2026-03-13 15:54:40 +01:00
committed by GitHub
parent 449e79de21
commit 2aff87d6d6
6 changed files with 160 additions and 20 deletions

View File

@@ -280,6 +280,36 @@ export function createServer(getClient: () => XoClient): McpServer {
}
)
// =============================================================================
// TOOL: list_vdis
// =============================================================================
server.registerTool(
'list_vdis',
{
title: 'List Virtual Disks',
description: 'List virtual disks (VDIs) in Xen Orchestra with optional filtering',
inputSchema: {
filter: z.string().optional().describe('Filter expression (e.g., VDI_type:User, name_label:data*)'),
fields: z.string().optional().describe('Comma-separated fields to return'),
limit: z.number().optional().describe('Maximum number of VDIs to return'),
},
},
async ({ filter, fields, limit }) => {
try {
const client = getClient()
const vdis = await client.listVdis({ filter, fields, limit })
return {
content: [{ type: 'text', text: JSON.stringify(vdis, null, 2) }],
}
} catch (error) {
return {
content: [{ type: 'text', text: `Failed to list VDIs: ${formatToolError(error)}` }],
isError: true,
}
}
}
)
// =============================================================================
// TOOL: get_vm_details
// =============================================================================

View File

@@ -17,6 +17,10 @@ function createMockClient(overrides: Record<string, unknown> = {}): XoClient {
{ id: 'vm1', name_label: 'VM 1', power_state: 'Running' },
{ id: 'vm2', name_label: 'VM 2', power_state: 'Halted' },
],
listVdis: async () => [
{ id: 'vdi1', name_label: 'VDI 1', size: 10737418240 },
{ id: 'vdi2', name_label: 'VDI 2', size: 21474836480 },
],
getVm: async () => ({ id: 'vm1', name_label: 'VM 1', power_state: 'Running' }),
getPool: async () => ({ id: 'pool1', name_label: 'Pool 1' }),
getPoolDashboard: async () => ({ hostsByStatus: { running: 1 } }),
@@ -40,7 +44,7 @@ async function setupTestServer(mockClient?: XoClient) {
describe('createServer', () => {
describe('tool listing', () => {
it('registers all 8 tools', async () => {
it('registers all 9 tools', async () => {
const { mcpClient } = await setupTestServer()
const { tools } = await mcpClient.listTools()
const toolNames = tools.map(t => t.name).sort()
@@ -52,6 +56,7 @@ describe('createServer', () => {
'get_vm_details',
'list_hosts',
'list_pools',
'list_vdis',
'list_vms',
'search_documentation',
])
@@ -141,6 +146,47 @@ describe('createServer', () => {
})
})
describe('list_vdis tool', () => {
it('returns VDIs as JSON', async () => {
const { mcpClient } = await setupTestServer()
const result = await mcpClient.callTool({ name: 'list_vdis', arguments: {} })
const text = (result.content as Array<{ type: string; text: string }>)[0].text
const parsed = JSON.parse(text)
assert.strictEqual(parsed.length, 2)
assert.strictEqual(parsed[0].id, 'vdi1')
})
it('passes filter, fields, and limit', async () => {
let receivedArgs: { filter?: string; fields?: string; limit?: number } = {}
const mockClient = createMockClient({
listVdis: async (options?: { filter?: string; fields?: string; limit?: number }) => {
receivedArgs = { filter: options?.filter, fields: options?.fields, limit: options?.limit }
return []
},
})
const { mcpClient } = await setupTestServer(mockClient)
await mcpClient.callTool({
name: 'list_vdis',
arguments: { filter: 'VDI_type:User', fields: 'id,size', limit: 10 },
})
assert.strictEqual(receivedArgs.filter, 'VDI_type:User')
assert.strictEqual(receivedArgs.fields, 'id,size')
assert.strictEqual(receivedArgs.limit, 10)
})
it('returns error on failure', async () => {
const mockClient = createMockClient({
listVdis: async () => {
throw new Error('Connection refused')
},
})
const { mcpClient } = await setupTestServer(mockClient)
const result = await mcpClient.callTool({ name: 'list_vdis', arguments: {} })
const text = (result.content as Array<{ type: string; text: string }>)[0].text
assert.ok(text.includes('Failed to list VDIs'))
})
})
describe('get_vm_details tool', () => {
it('returns VM details', async () => {
const { mcpClient } = await setupTestServer()

View File

@@ -5,10 +5,16 @@
* Authentication is done via Basic Auth or token cookie.
*/
import type { XoPool, XoHost, XoVm } from '@vates/types/xo'
import type { XoPool, XoHost, XoVm, XoVdi } from '@vates/types/xo'
import type { XapiVmStats, XapiStatsGranularity } from '@vates/types/common'
export type { XoPool, XoHost, XoVm, XapiVmStats, XapiStatsGranularity }
export type { XoPool, XoHost, XoVm, XoVdi, XapiVmStats, XapiStatsGranularity }
export interface ListOptions {
filter?: string
fields?: string
limit?: number
}
const REQUEST_TIMEOUT_MS = 30_000
@@ -96,6 +102,18 @@ export class XoClient {
}
}
private buildListParams(defaultFields: string, options?: ListOptions): URLSearchParams {
const params = new URLSearchParams()
params.set('fields', options?.fields ?? defaultFields)
if (options?.filter) {
params.set('filter', options.filter)
}
if (options?.limit !== undefined) {
params.set('limit', String(options.limit))
}
return params
}
async listPools(fields?: string): Promise<Partial<XoPool>[]> {
const params = new URLSearchParams()
params.set('fields', fields ?? 'id,name_label,name_description,auto_poweron,HA_enabled')
@@ -111,13 +129,8 @@ export class XoClient {
return this.request<XoPoolDashboard>(`/pools/${encodeURIComponent(poolId)}/dashboard?ndjson=false`)
}
async listHosts(options?: { filter?: string; fields?: string }): Promise<Partial<XoHost>[]> {
const params = new URLSearchParams()
params.set('fields', options?.fields ?? 'id,name_label,productBrand,version,power_state')
if (options?.filter) {
params.set('filter', options.filter)
}
async listHosts(options?: ListOptions): Promise<Partial<XoHost>[]> {
const params = this.buildListParams('id,name_label,productBrand,version,power_state', options)
return this.request<Partial<XoHost>[]>(`/hosts?${params}`)
}
@@ -125,19 +138,16 @@ export class XoClient {
return this.request<XoHost>(`/hosts/${encodeURIComponent(hostId)}`)
}
async listVms(options?: { filter?: string; fields?: string; limit?: number }): Promise<Partial<XoVm>[]> {
const params = new URLSearchParams()
params.set('fields', options?.fields ?? 'id,name_label,power_state,CPUs,memory')
if (options?.filter) {
params.set('filter', options.filter)
}
if (options?.limit !== undefined) {
params.set('limit', String(options.limit))
}
async listVms(options?: ListOptions): Promise<Partial<XoVm>[]> {
const params = this.buildListParams('id,name_label,power_state,CPUs,memory', options)
return this.request<Partial<XoVm>[]>(`/vms?${params}`)
}
async listVdis(options?: ListOptions): Promise<Partial<XoVdi>[]> {
const params = this.buildListParams('id,name_label,name_description,$SR,size,usage,VDI_type', options)
return this.request<Partial<XoVdi>[]>(`/vdis?${params}`)
}
async getVm(vmId: string): Promise<XoVm> {
return this.request<XoVm>(`/vms/${encodeURIComponent(vmId)}`)
}

View File

@@ -285,6 +285,44 @@ describe('XoClient', () => {
})
})
describe('listVdis', () => {
it('returns VDIs with default fields', async () => {
const vdis = [{ id: 'vdi1', name_label: 'VDI 1', size: 10737418240 }]
const client = new XoClient({ url: 'http://xo.local:9000', username: 'admin', password: 'pass' })
globalThis.fetch = async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
assert.ok(url.includes('fields=id%2Cname_label%2Cname_description%2C%24SR%2Csize%2Cusage%2CVDI_type'))
return mockResponse(vdis)
}
const result = await client.listVdis()
assert.deepStrictEqual(result, vdis)
})
it('passes filter, fields, and limit via object', async () => {
const client = new XoClient({ url: 'http://xo.local:9000', username: 'admin', password: 'pass' })
globalThis.fetch = async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
assert.ok(url.includes('filter=VDI_type'))
assert.ok(url.includes('limit=10'))
return mockResponse([])
}
await client.listVdis({ filter: 'VDI_type:User', fields: 'id,size', limit: 10 })
})
it('passes limit=0 correctly', async () => {
const client = new XoClient({ url: 'http://xo.local:9000', username: 'admin', password: 'pass' })
globalThis.fetch = async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input.toString()
assert.ok(url.includes('limit=0'), 'limit=0 should be included in URL')
return mockResponse([])
}
await client.listVdis({ limit: 0 })
})
})
describe('getVm', () => {
it('returns VM details', async () => {
const vm = { id: 'vm1', name_label: 'VM 1', power_state: 'Running' }

View File

@@ -12,6 +12,7 @@
> Users must be able to say: "Nice enhancement, I'm eager to test it"
- [MCP] Support token authentication via `XO_TOKEN` environment variable as an alternative to username/password (PR [#9577](https://github.com/vatesfr/xen-orchestra/pull/9577))
- [MCP] Add `list_vdis` tool to list virtual disks (PR [#9559](https://github.com/vatesfr/xen-orchestra/pull/9559))
- [Replication] Reuse the same VM as an incremental replication target (PR [#9524](https://github.com/vatesfr/xen-orchestra/pull/9524))
- [S3] add configuration for max/minPartSize and maxPartNumber in the API (PR [#9561](https://github.com/vatesfr/xen-orchestra/pull/9561))
- [REST API] Expose `/rest/v0/vms/:id/actions/clone` (PR [#9453](https://github.com/vatesfr/xen-orchestra/pull/9453))

View File

@@ -184,6 +184,7 @@ List all hosts (hypervisors) in Xen Orchestra with optional filtering.
| --------- | ------ | -------- | ---------------------------------------------------------------------------------- |
| `filter` | string | No | Filter expression (e.g., `productBrand:XCP-ng`) |
| `fields` | string | No | Comma-separated fields (default: `id,name_label,productBrand,version,power_state`) |
| `limit` | number | No | Maximum number of results |
**Example question:** "Show me all XCP-ng hosts"
@@ -203,6 +204,20 @@ List virtual machines in Xen Orchestra with optional filtering.
---
### `list_vdis`
List virtual disks (VDIs) in Xen Orchestra with optional filtering.
| Parameter | Type | Required | Description |
| --------- | ------ | -------- | ------------------------------------------------------------------------------------------ |
| `filter` | string | No | Filter expression (e.g., `VDI_type:User`, `name_label:backup*`) |
| `fields` | string | No | Comma-separated fields (default: `id,name_label,name_description,$SR,size,usage,VDI_type`) |
| `limit` | number | No | Maximum number of results |
**Example question:** "List all user VDIs larger than 100GB"
---
### `get_vm_details`
Get detailed information about a specific virtual machine.