mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-09-10 22:14:48 -05:00
feat(mcp): add list_vdis tool to list virtual disks (#9559)
This commit is contained in:
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)}`)
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user