Skip to content

Commit 894ca9b

Browse files
committed
feat: add support for URL variables in remote servers
1 parent 6553cb3 commit 894ca9b

File tree

3 files changed

+105
-9
lines changed

3 files changed

+105
-9
lines changed

src/components/server-card.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,9 +347,17 @@ export const ServerCard = ({
347347
<Button variant="outline" size="sm" className="text-xs" onClick={(e) => e.stopPropagation()}>
348348
{getRemoteIcon(remote)}
349349
<span className="break- font-mono text-muted-foreground">
350-
{remote.url?.replace('https://', '')}
350+
{(() => {
351+
let displayUrl = remote.url?.replace('https://', '') || '';
352+
if (remote.variables) {
353+
Object.entries(remote.variables).forEach(([key, varDef]) => {
354+
displayUrl = displayUrl.replace(`{${key}}`, String(varDef.default ?? `{${key}}`));
355+
});
356+
}
357+
return displayUrl;
358+
})()}
351359
</span>
352-
{remote.headers && Object.keys(remote.headers).length > 0 && (
360+
{((remote.headers && remote.headers.length > 0) || remote.variables) && (
353361
<Settings className="text-slate-400 flex-shrink-0" />
354362
)}
355363
</Button>

src/components/server-remote.tsx

Lines changed: 94 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@ export const ServerRemote = ({
3333
headers:
3434
userConfig?.headers ??
3535
(remote.headers ? Object.fromEntries(remote.headers.map((ev) => [ev.name, ev.value ?? ev.default ?? ''])) : {}),
36+
variables: remote.variables
37+
? Object.fromEntries(Object.entries(remote.variables).map(([k, v]) => [k, v.value ?? v.default ?? '']))
38+
: {},
3639
};
3740

38-
// Build a per-remote zod schema so we can mark remote-declared headers as required
41+
// Build a per-remote zod schema so we can mark remote-declared headers and variables as required
3942
const formSchema = useMemo(() => {
4043
let headersSchema = z.record(z.string(), z.string());
4144
if (remote.headers && remote.headers.length > 0) {
@@ -56,8 +59,28 @@ export const ServerRemote = ({
5659
});
5760
}
5861
}
59-
return z.object({ headers: headersSchema });
60-
}, [remote.headers]);
62+
let variablesSchema = z.record(z.string(), z.string());
63+
if (remote.variables) {
64+
const requiredVarNames = Object.entries(remote.variables)
65+
.filter(([_, v]) => v.isRequired)
66+
.map(([k, _]) => k);
67+
if (requiredVarNames.length > 0) {
68+
variablesSchema = variablesSchema.superRefine((rec: Record<string, unknown>, ctx) => {
69+
requiredVarNames.forEach((name: string) => {
70+
const v = rec[name];
71+
if (typeof v !== 'string' || v.trim().length === 0) {
72+
ctx.addIssue({
73+
code: 'custom',
74+
message: `${name} is required`,
75+
path: [name],
76+
});
77+
}
78+
});
79+
});
80+
}
81+
}
82+
return z.object({ headers: headersSchema, variables: variablesSchema });
83+
}, [remote.headers, remote.variables]);
6184

6285
const form = useForm<z.infer<typeof formSchema>>({
6386
resolver: zodResolver(formSchema),
@@ -68,10 +91,20 @@ export const ServerRemote = ({
6891
const watchedValues = useWatch({ control: form.control }) as z.infer<typeof formSchema> | undefined;
6992
const formValues = useMemo(() => {
7093
const config: McpIdeConfigRemote = { type: remote.type || '' };
71-
if (remote.url) config.url = remote.url;
94+
// Resolve URL with variables if present
95+
if (remote.url) {
96+
let resolvedUrl = remote.url;
97+
if (watchedValues?.variables && Object.keys(watchedValues.variables).length > 0) {
98+
Object.entries(watchedValues.variables).forEach(([key, value]) => {
99+
resolvedUrl = resolvedUrl.replace(`{${key}}`, value);
100+
});
101+
}
102+
config.url = resolvedUrl;
103+
}
72104
if (watchedValues?.headers && Object.keys(watchedValues.headers).length > 0) {
73105
config.headers = watchedValues.headers;
74106
}
107+
// Note: variables are NOT included in the final config, they're only used to resolve the URL
75108
return config;
76109
}, [remote.type, remote.url, watchedValues]);
77110

@@ -99,15 +132,15 @@ export const ServerRemote = ({
99132
<DialogHeader>
100133
<DialogTitle className="flex gap-2">
101134
<a
102-
href={remote.url}
135+
href={formValues.url || remote.url}
103136
target="_blank"
104137
rel="noopener noreferrer"
105138
className="flex gap-2 items-center hover:text-muted-foreground"
106139
>
107140
{getRemoteIcon(remote)}
108-
{remote.url}
141+
{formValues.url || remote.url}
109142
</a>
110-
<CopyButton content={remote.url} variant="outline" size="sm" />
143+
<CopyButton content={formValues.url || remote.url || ''} variant="outline" size="sm" />
111144
</DialogTitle>
112145
<DialogDescription className="mt-2">
113146
<span>🚛 Transport:</span> <code className="text-primary">{remote.type}</code>
@@ -116,6 +149,60 @@ export const ServerRemote = ({
116149
{/* Remote server details */}
117150
<Form {...form}>
118151
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
152+
{remote.variables && Object.keys(remote.variables).length > 0 && (
153+
<div>
154+
<span className="text-muted-foreground">⚙️ URL Variables:</span>
155+
<div className="mt-2 space-y-2">
156+
{Object.entries(remote.variables).map(([varName, varDef]) => (
157+
<FormField
158+
key={varName}
159+
control={form.control}
160+
name={`variables.${varName}`}
161+
render={({ field }) => (
162+
<div className="text-xs">
163+
<div className="flex items-center gap-2">
164+
<FormItem className="flex-1">
165+
<FormLabel>
166+
<code>{varName}</code>
167+
{varDef.format && <Badge variant="outline">{varDef.format}</Badge>}{' '}
168+
{varDef.isRequired && <span className="text-red-500">*</span>}{' '}
169+
{varDef.isSecret && <span>🔒</span>}
170+
</FormLabel>
171+
<FormControl>
172+
{varDef.isSecret ? (
173+
<PasswordInput
174+
required={varDef.isRequired}
175+
placeholder={varDef.default ?? varName ?? ''}
176+
{...field}
177+
/>
178+
) : (
179+
<Input
180+
required={varDef.isRequired}
181+
placeholder={varDef.default ?? varName ?? ''}
182+
{...field}
183+
/>
184+
)}
185+
</FormControl>
186+
<FormDescription>{varDef.description}</FormDescription>
187+
<FormMessage />
188+
</FormItem>
189+
</div>
190+
{varDef.choices && varDef.choices.length > 0 && (
191+
<div className="ml-4 mt-1 flex flex-wrap md:flex-nowrap gap-1">
192+
{varDef.choices.map((choice: string) => (
193+
<Badge key={choice} variant="secondary" className="text-xs px-1 py-0">
194+
{choice}
195+
</Badge>
196+
))}
197+
</div>
198+
)}
199+
</div>
200+
)}
201+
/>
202+
))}
203+
</div>
204+
</div>
205+
)}
119206
{remote.headers && remote.headers.length > 0 && (
120207
<div>
121208
<span className="text-muted-foreground">⚙️ Headers:</span>

src/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export interface McpServerRemote {
8484
type: string;
8585
url?: string;
8686
headers?: Array<EnvVarOrHeader>;
87+
variables?: Record<string, EnvVarOrHeader>;
8788
}
8889

8990
export interface EnvVarOrHeader {

0 commit comments

Comments
 (0)