Skip to content

Commit dbde497

Browse files
kantordpeppescg
andauthored
feat: support mounting storage volumes (#701)
* add storage volumes field to registry servers * make array fields more generic * fix type errors * test(e2e): install 'everything' instead of 'fetch' * add failing tests for volume mount field * cleanup * Revert "add failing tests for volume mount field" This reverts commit 5516d78. * feat: configure mcp volumes * refactor: volume tooltip description * feat: add storage volumes to custom run * fix: ro access mode support * refactor: remove filter sql, filesystem mcp * refactor: share build volumes fn * refactor: custom run and registry form --------- Co-authored-by: Giuseppe Scuglia <[email protected]>
1 parent 182bd37 commit dbde497

19 files changed

+746
-72
lines changed

e2e-tests/open-toolhive.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ test('install & uninstall server', async ({ window }) => {
1212
await window.getByRole('link', { name: /browse registry/i }).click()
1313
await window
1414
.getByRole('button', {
15-
name: /fetch/i,
15+
name: /everything/i,
1616
})
1717
.click()
1818
await window

renderer/src/common/lib/utils.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,18 @@ export function cn(...inputs: ClassValue[]) {
1212
export function isEmptyEnvVar(value: string | undefined | null): boolean {
1313
return !value || value.trim() === ''
1414
}
15+
16+
export function getVolumes(
17+
volumes: Array<{
18+
host: string
19+
container: string
20+
accessMode?: 'ro' | 'rw'
21+
}>
22+
): Array<string> {
23+
return volumes
24+
.filter((volume) => volume.host && volume.container)
25+
.map(
26+
(volume) =>
27+
`${volume.host}:${volume.container}${volume.accessMode === 'ro' ? ':ro' : ''}`
28+
)
29+
}

renderer/src/features/mcp-servers/components/dialog-form-run-mcp-command.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@ import {
2929
useFormTabState,
3030
type FieldTabMapping,
3131
} from '@/common/hooks/use-form-tab-state'
32+
import { NetworkIsolationTabContent } from './network-isolation-tab-content'
33+
import { FormFieldsArrayCustomVolumes } from './form-fields-array-custom-volumes'
3234

3335
type Tab = 'configuration' | 'network-isolation'
3436
type CommonFields = keyof FormSchemaRunMcpCommand
3537
type VariantSpecificFields = 'image' | 'protocol' | 'package_name'
3638
type Field = CommonFields | VariantSpecificFields
3739

38-
import { NetworkIsolationTabContent } from './network-isolation-tab-content'
39-
4040
const FIELD_TAB_MAP = {
4141
name: 'configuration',
4242
transport: 'configuration',
@@ -51,6 +51,7 @@ const FIELD_TAB_MAP = {
5151
allowedHosts: 'network-isolation',
5252
allowedPorts: 'network-isolation',
5353
networkIsolation: 'network-isolation',
54+
volumes: 'configuration',
5455
} satisfies FieldTabMapping<Tab, Field>
5556

5657
export function DialogFormRunMcpServerWithCommand({
@@ -106,6 +107,7 @@ export function DialogFormRunMcpServerWithCommand({
106107
networkIsolation: false,
107108
allowedHosts: [],
108109
allowedPorts: [],
110+
volumes: [{ host: '', container: '', accessMode: 'rw' }],
109111
},
110112
reValidateMode: 'onChange',
111113
mode: 'onChange',
@@ -198,6 +200,7 @@ export function DialogFormRunMcpServerWithCommand({
198200
<FormFieldsRunMcpCommand form={form} />
199201
<FormFieldsArrayCustomSecrets form={form} />
200202
<FormFieldsArrayCustomEnvVars form={form} />
203+
<FormFieldsArrayCustomVolumes form={form} />
201204
</div>
202205
)}
203206
{activeTab === 'network-isolation' && (
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { FormControl, FormField, FormItem } from '@/common/components/ui/form'
2+
import { Controller, type UseFormReturn } from 'react-hook-form'
3+
import { Input } from '@/common/components/ui/input'
4+
import {
5+
Select,
6+
SelectTrigger,
7+
SelectContent,
8+
SelectItem,
9+
} from '@/common/components/ui/select'
10+
import { FolderCheck, FolderLock } from 'lucide-react'
11+
import type { FormSchemaRunMcpCommand } from '../lib/form-schema-run-mcp-server-with-command'
12+
import { DynamicArrayField } from '@/features/registry-servers/components/dynamic-array-field'
13+
14+
type AccessMode = 'ro' | 'rw'
15+
type Volume = {
16+
host?: string
17+
container?: string
18+
accessMode?: AccessMode
19+
}
20+
21+
const getAccessModeDisplay = (value: Volume['accessMode'] | undefined) => {
22+
switch (value) {
23+
case 'ro':
24+
return <FolderLock className="size-4" />
25+
case 'rw':
26+
default:
27+
return <FolderCheck className="size-4" />
28+
}
29+
}
30+
31+
export function FormFieldsArrayCustomVolumes({
32+
form,
33+
}: {
34+
form: UseFormReturn<FormSchemaRunMcpCommand>
35+
}) {
36+
return (
37+
<FormItem className="mb-10">
38+
<Controller
39+
control={form.control}
40+
name="volumes"
41+
render={() => (
42+
<DynamicArrayField<FormSchemaRunMcpCommand>
43+
name="volumes"
44+
label="Storage volumes"
45+
inputLabelPrefix="Storage volume"
46+
addButtonText="Add a volume"
47+
description="Provide the MCP server access to a local folder. Optionally specific individual files."
48+
form={form}
49+
>
50+
{({ inputProps, setInputRef, idx }) => (
51+
<FormField
52+
control={form.control}
53+
name={`volumes.${idx}`}
54+
render={({ field }) => {
55+
const volumeValue = field.value as Volume
56+
57+
return (
58+
<>
59+
<FormItem className="flex-grow">
60+
<div className="flex w-full gap-2">
61+
<FormControl className="flex-1">
62+
<Input
63+
{...inputProps}
64+
type="string"
65+
ref={setInputRef(idx)}
66+
aria-label={`Host path ${idx + 1}`}
67+
name={`volumes.${idx}.host`}
68+
value={volumeValue?.host || ''}
69+
onChange={(e) =>
70+
field.onChange({
71+
...volumeValue,
72+
host: e.target.value,
73+
})
74+
}
75+
placeholder="Host path"
76+
/>
77+
</FormControl>
78+
<FormControl className="flex-1">
79+
<Input
80+
{...inputProps}
81+
type="string"
82+
ref={setInputRef(idx)}
83+
aria-label={`Container path ${idx + 1}`}
84+
name={`volumes.${idx}.container`}
85+
value={volumeValue?.container || ''}
86+
onChange={(e) =>
87+
field.onChange({
88+
...volumeValue,
89+
container: e.target.value,
90+
})
91+
}
92+
placeholder="Container path"
93+
/>
94+
</FormControl>
95+
<FormControl className="w-48 flex-shrink-0">
96+
<Select
97+
onValueChange={(value) =>
98+
field.onChange({
99+
...volumeValue,
100+
accessMode: value as AccessMode,
101+
})
102+
}
103+
value={volumeValue.accessMode}
104+
>
105+
<SelectTrigger>
106+
{getAccessModeDisplay(volumeValue.accessMode)}
107+
</SelectTrigger>
108+
<SelectContent>
109+
<SelectItem value="ro">
110+
<div className="flex items-center gap-2">
111+
<FolderLock className="size-4" />
112+
<span>Read only access</span>
113+
</div>
114+
</SelectItem>
115+
<SelectItem value="rw">
116+
<div className="flex items-center gap-2">
117+
<FolderCheck className="size-4" />
118+
<span>Read & write access</span>
119+
</div>
120+
</SelectItem>
121+
</SelectContent>
122+
</Select>
123+
</FormControl>
124+
</div>
125+
</FormItem>
126+
</>
127+
)
128+
}}
129+
/>
130+
)}
131+
</DynamicArrayField>
132+
)}
133+
/>
134+
</FormItem>
135+
)
136+
}

renderer/src/features/mcp-servers/components/network-isolation-tab-content.tsx

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import { Label } from '@/common/components/ui/label'
44
import { Alert, AlertDescription } from '@/common/components/ui/alert'
55
import { AlertTriangle } from 'lucide-react'
66
import { DynamicArrayField } from '../../registry-servers/components/dynamic-array-field'
7-
import type { UseFormReturn } from 'react-hook-form'
7+
import type {
8+
ControllerRenderProps,
9+
Path,
10+
UseFormReturn,
11+
} from 'react-hook-form'
812
import { type FormSchemaRunMcpCommand } from '../lib/form-schema-run-mcp-server-with-command'
13+
import { FormControl, FormField, FormItem } from '@/common/components/ui/form'
14+
import { Input } from '@/common/components/ui/input'
915

1016
export function NetworkIsolationTabContent({
1117
form,
@@ -60,7 +66,42 @@ export function NetworkIsolationTabContent({
6066
addButtonText="Add a host"
6167
tooltipContent={`Specify domain names or IP addresses. To include subdomains, use a leading period (".")`}
6268
form={form}
63-
/>
69+
>
70+
{({
71+
fieldProps,
72+
inputProps,
73+
setInputRef,
74+
idx,
75+
message,
76+
}) => (
77+
<FormField
78+
{...fieldProps}
79+
render={({
80+
field,
81+
}: {
82+
field: ControllerRenderProps<
83+
FormSchemaRunMcpCommand,
84+
Path<FormSchemaRunMcpCommand>
85+
>
86+
}) => (
87+
<FormItem className="flex-grow">
88+
<FormControl className="w-full">
89+
<Input
90+
{...field}
91+
{...inputProps}
92+
type="string"
93+
ref={setInputRef(idx)}
94+
aria-label={`Host ${idx + 1}`}
95+
className="min-w-0 grow"
96+
value={field.value as string}
97+
/>
98+
</FormControl>
99+
{message}
100+
</FormItem>
101+
)}
102+
/>
103+
)}
104+
</DynamicArrayField>
64105
)}
65106
/>
66107
<Controller
@@ -72,9 +113,43 @@ export function NetworkIsolationTabContent({
72113
label="Allowed ports"
73114
inputLabelPrefix="Port"
74115
addButtonText="Add a port"
75-
type="number"
76116
form={form}
77-
/>
117+
>
118+
{({
119+
fieldProps,
120+
inputProps,
121+
setInputRef,
122+
idx,
123+
message,
124+
}) => (
125+
<FormField
126+
{...fieldProps}
127+
render={({
128+
field,
129+
}: {
130+
field: ControllerRenderProps<
131+
FormSchemaRunMcpCommand,
132+
Path<FormSchemaRunMcpCommand>
133+
>
134+
}) => (
135+
<FormItem className="flex-grow">
136+
<FormControl className="w-full">
137+
<Input
138+
{...field}
139+
{...inputProps}
140+
type="number"
141+
ref={setInputRef(idx)}
142+
aria-label={`Port ${idx + 1}`}
143+
className="min-w-0 grow"
144+
value={field.value as string}
145+
/>
146+
</FormControl>
147+
{message}
148+
</FormItem>
149+
)}
150+
/>
151+
)}
152+
</DynamicArrayField>
78153
)}
79154
/>
80155
</>

0 commit comments

Comments
 (0)