Skip to content

Commit c5ae9c5

Browse files
committed
feat: add extra_headers support and comprehensive tests for custom inputs
Add extra_headers (HTTP headers) support alongside the existing env_overrides and args_append fields, completing the full custom input story for server configuration: - stdio servers: additional args + env vars - http servers: env vars + custom HTTP headers Backend: update_config and save_server_inputs now accept extra_headers. Frontend: config modal shows HTTP Headers key-value editor for http transport servers. Tests: - Unit tests for InstalledServer custom fields (env_overrides, args_append, extra_headers) including serialization round-trip and default value verification. - Integration tests for database persistence of all custom fields through install/update cycles. - E2E Playwright tests for config modal custom input sections: visibility by transport type, add/remove env vars, add/remove headers, multi-line args input. Closes #49 https://claude.ai/code/session_01V5tgbLyeWrPW5zZ1toRoPZ
1 parent a6caa18 commit c5ae9c5

8 files changed

Lines changed: 817 additions & 7 deletions

File tree

apps/desktop/src-tauri/src/commands/server.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ pub async fn save_server_inputs(
135135
space_id: String,
136136
env_overrides: Option<HashMap<String, String>>,
137137
args_append: Option<Vec<String>>,
138+
extra_headers: Option<HashMap<String, String>>,
138139
) -> Result<InstalledServer, String> {
139140
let service_lock = app_service.read().await;
140141
let service = service_lock
@@ -144,7 +145,7 @@ pub async fn save_server_inputs(
144145
let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?;
145146

146147
service
147-
.update_config(space_uuid, &id, input_values, env_overrides, args_append)
148+
.update_config(space_uuid, &id, input_values, env_overrides, args_append, extra_headers)
148149
.await
149150
.map_err(|e| e.to_string())
150151
}

apps/desktop/src/features/servers/ServersPage.tsx

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ function mergeDefinitionsWithStates(
6767
installation_source: state?.source, // Track how server was installed
6868
env_overrides: state?.env_overrides ?? {},
6969
args_append: state?.args_append ?? [],
70+
extra_headers: state?.extra_headers ?? {},
7071
} as ServerViewModel;
7172
});
7273
}
@@ -100,6 +101,7 @@ function createOfflineServerViewModel(state: InstalledServerState): ServerViewMo
100101
installation_source: state.source,
101102
env_overrides: state.env_overrides ?? {},
102103
args_append: state.args_append ?? [],
104+
extra_headers: state.extra_headers ?? {},
103105
} as ServerViewModel;
104106
} catch (e) {
105107
console.warn('[ServersPage] Failed to parse cached_definition, using minimal fallback:', e);
@@ -135,6 +137,7 @@ function createOfflineServerViewModel(state: InstalledServerState): ServerViewMo
135137
installation_source: state.source,
136138
env_overrides: state.env_overrides ?? {},
137139
args_append: state.args_append ?? [],
140+
extra_headers: state.extra_headers ?? {},
138141
} as ServerViewModel;
139142
}
140143

@@ -149,6 +152,8 @@ interface ConfigModalState {
149152
envOverrides: Record<string, string>;
150153
/** Additional arguments to append (stdio only) */
151154
argsAppend: string[];
155+
/** Extra HTTP headers (http only) */
156+
extraHeaders: Record<string, string>;
152157
}
153158

154159
export function ServersPage() {
@@ -165,6 +170,7 @@ export function ServersPage() {
165170
inputValues: {},
166171
envOverrides: {},
167172
argsAppend: [],
173+
extraHeaders: {},
168174
});
169175

170176
// Features state
@@ -502,6 +508,7 @@ export function ServersPage() {
502508
enableOnSave: true, // This is from Enable flow
503509
envOverrides: { ...(server.env_overrides ?? {}) },
504510
argsAppend: [...(server.args_append ?? [])],
511+
extraHeaders: { ...(server.extra_headers ?? {}) },
505512
});
506513
return;
507514
}
@@ -565,6 +572,7 @@ export function ServersPage() {
565572
enableOnSave: false, // Just configure, don't enable
566573
envOverrides: { ...(server.env_overrides ?? {}) },
567574
argsAppend: [...(server.args_append ?? [])],
575+
extraHeaders: { ...(server.extra_headers ?? {}) },
568576
});
569577
};
570578

@@ -579,16 +587,17 @@ export function ServersPage() {
579587
try {
580588
const { saveServerInputs } = await import('@/lib/api/registry');
581589

582-
// Save input values with env overrides and args
590+
// Save input values with env overrides, args, and headers
583591
await saveServerInputs(
584592
serverId,
585593
configModal.inputValues,
586594
viewSpace?.id ?? '',
587595
Object.keys(configModal.envOverrides).length > 0 ? configModal.envOverrides : undefined,
588596
configModal.argsAppend.length > 0 ? configModal.argsAppend : undefined,
597+
Object.keys(configModal.extraHeaders).length > 0 ? configModal.extraHeaders : undefined,
589598
);
590599

591-
setConfigModal({ open: false, server: null, inputValues: {}, envOverrides: {}, argsAppend: [] });
600+
setConfigModal({ open: false, server: null, inputValues: {}, envOverrides: {}, argsAppend: [], extraHeaders: {} });
592601

593602
// Only enable if requested (from Enable flow)
594603
if (shouldEnable && !server.enabled) {
@@ -626,7 +635,7 @@ export function ServersPage() {
626635
// Set the server to pending_config state by enabling but not connecting
627636
// Actually, we just close the modal - the UI already shows Configure button for missing inputs
628637
}
629-
setConfigModal({ open: false, server: null, inputValues: {}, envOverrides: {}, argsAppend: [] });
638+
setConfigModal({ open: false, server: null, inputValues: {}, envOverrides: {}, argsAppend: [], extraHeaders: {} });
630639
};
631640

632641
// Cancel OAuth flow - uses new ServerManager v2
@@ -1433,6 +1442,72 @@ export function ServersPage() {
14331442
</div>
14341443
</div>
14351444

1445+
{/* Extra HTTP Headers (http only) */}
1446+
{configModal.server.transport.type === 'http' && (
1447+
<div>
1448+
<label className="block text-sm font-medium text-[rgb(var(--foreground))] mb-1">
1449+
HTTP Headers
1450+
</label>
1451+
<p className="text-xs text-[rgb(var(--muted))] mb-2">
1452+
Custom HTTP headers sent with each request
1453+
</p>
1454+
<div className="space-y-2">
1455+
{Object.entries(configModal.extraHeaders).map(([key, value], idx) => (
1456+
<div key={idx} className="flex gap-2">
1457+
<input
1458+
type="text"
1459+
value={key}
1460+
onChange={(e) => {
1461+
const entries = Object.entries(configModal.extraHeaders);
1462+
entries[idx] = [e.target.value, value];
1463+
setConfigModal({
1464+
...configModal,
1465+
extraHeaders: Object.fromEntries(entries),
1466+
});
1467+
}}
1468+
placeholder="Header-Name"
1469+
className="input flex-1 font-mono text-sm"
1470+
/>
1471+
<input
1472+
type="text"
1473+
value={value}
1474+
onChange={(e) => {
1475+
setConfigModal({
1476+
...configModal,
1477+
extraHeaders: { ...configModal.extraHeaders, [key]: e.target.value },
1478+
});
1479+
}}
1480+
placeholder="value"
1481+
className="input flex-1 font-mono text-sm"
1482+
/>
1483+
<button
1484+
onClick={() => {
1485+
const { [key]: _, ...rest } = configModal.extraHeaders;
1486+
setConfigModal({ ...configModal, extraHeaders: rest });
1487+
}}
1488+
className="px-2 py-1 text-sm text-[rgb(var(--muted))] hover:text-[rgb(var(--error))] transition-colors"
1489+
title="Remove"
1490+
>
1491+
1492+
</button>
1493+
</div>
1494+
))}
1495+
<button
1496+
onClick={() => {
1497+
setConfigModal({
1498+
...configModal,
1499+
extraHeaders: { ...configModal.extraHeaders, '': '' },
1500+
});
1501+
}}
1502+
className="text-xs text-[rgb(var(--primary))] hover:underline"
1503+
data-testid="config-add-header"
1504+
>
1505+
+ Add header
1506+
</button>
1507+
</div>
1508+
</div>
1509+
)}
1510+
14361511
<div className="flex justify-end gap-2 pt-2">
14371512
<button
14381513
onClick={handleCancelConfig}

apps/desktop/src/lib/api/registry.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ export async function saveServerInputs(
8686
inputValues: Record<string, string>,
8787
spaceId: string,
8888
envOverrides?: Record<string, string>,
89-
argsAppend?: string[]
89+
argsAppend?: string[],
90+
extraHeaders?: Record<string, string>
9091
): Promise<void> {
91-
return invoke<void>('save_server_inputs', { id, inputValues, spaceId, envOverrides, argsAppend });
92+
return invoke<void>('save_server_inputs', { id, inputValues, spaceId, envOverrides, argsAppend, extraHeaders });
9293
}

apps/desktop/src/types/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ export interface ServerViewModel extends ServerDefinition {
119119
env_overrides?: Record<string, string>;
120120
/** Extra arguments to append to command (stdio only) */
121121
args_append?: string[];
122+
/** Extra HTTP headers (http only) */
123+
extra_headers?: Record<string, string>;
122124
}
123125

124126
/** Registry category */

crates/mcpmux-core/src/application/server.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ impl ServerAppService {
237237
Ok(())
238238
}
239239

240-
/// Update server configuration (inputs, env overrides, args)
240+
/// Update server configuration (inputs, env overrides, args, headers)
241241
///
242242
/// Emits: `ServerConfigUpdated`
243243
pub async fn update_config(
@@ -247,6 +247,7 @@ impl ServerAppService {
247247
input_values: HashMap<String, String>,
248248
env_overrides: Option<HashMap<String, String>>,
249249
args_append: Option<Vec<String>>,
250+
extra_headers: Option<HashMap<String, String>>,
250251
) -> Result<InstalledServer> {
251252
let space_id_str = space_id.to_string();
252253

@@ -263,6 +264,9 @@ impl ServerAppService {
263264
if let Some(args) = args_append {
264265
server.args_append = args;
265266
}
267+
if let Some(headers) = extra_headers {
268+
server.extra_headers = headers;
269+
}
266270
server.updated_at = chrono::Utc::now();
267271

268272
self.server_repo.update(&server).await?;

crates/mcpmux-core/src/domain/installed_server.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,129 @@ mod tests {
213213
Some(&"ghp_xxxxx".to_string())
214214
);
215215
}
216+
217+
#[test]
218+
fn test_new_server_has_empty_custom_fields() {
219+
let server = InstalledServer::new("space_default", "test-server");
220+
221+
assert!(
222+
server.env_overrides.is_empty(),
223+
"New server should have empty env_overrides"
224+
);
225+
assert!(
226+
server.args_append.is_empty(),
227+
"New server should have empty args_append"
228+
);
229+
assert!(
230+
server.extra_headers.is_empty(),
231+
"New server should have empty extra_headers"
232+
);
233+
}
234+
235+
#[test]
236+
fn test_env_overrides_can_be_set() {
237+
let mut server = InstalledServer::new("space_default", "test-server");
238+
server
239+
.env_overrides
240+
.insert("NODE_ENV".to_string(), "production".to_string());
241+
server
242+
.env_overrides
243+
.insert("DEBUG".to_string(), "true".to_string());
244+
245+
assert_eq!(server.env_overrides.len(), 2);
246+
assert_eq!(
247+
server.env_overrides.get("NODE_ENV"),
248+
Some(&"production".to_string())
249+
);
250+
assert_eq!(
251+
server.env_overrides.get("DEBUG"),
252+
Some(&"true".to_string())
253+
);
254+
}
255+
256+
#[test]
257+
fn test_args_append_can_be_set() {
258+
let mut server = InstalledServer::new("space_default", "test-server");
259+
server.args_append = vec![
260+
"--verbose".to_string(),
261+
"--port".to_string(),
262+
"8080".to_string(),
263+
];
264+
265+
assert_eq!(server.args_append.len(), 3);
266+
assert_eq!(server.args_append[0], "--verbose");
267+
assert_eq!(server.args_append[1], "--port");
268+
assert_eq!(server.args_append[2], "8080");
269+
}
270+
271+
#[test]
272+
fn test_extra_headers_can_be_set() {
273+
let mut server = InstalledServer::new("space_default", "test-server");
274+
server
275+
.extra_headers
276+
.insert("Authorization".to_string(), "Bearer token123".to_string());
277+
server
278+
.extra_headers
279+
.insert("X-Custom-Header".to_string(), "custom-value".to_string());
280+
281+
assert_eq!(server.extra_headers.len(), 2);
282+
assert_eq!(
283+
server.extra_headers.get("Authorization"),
284+
Some(&"Bearer token123".to_string())
285+
);
286+
assert_eq!(
287+
server.extra_headers.get("X-Custom-Header"),
288+
Some(&"custom-value".to_string())
289+
);
290+
}
291+
292+
#[test]
293+
fn test_custom_fields_serialize_deserialize() {
294+
let mut server = InstalledServer::new("space_default", "test-server");
295+
server
296+
.env_overrides
297+
.insert("KEY".to_string(), "value".to_string());
298+
server.args_append = vec!["--flag".to_string()];
299+
server
300+
.extra_headers
301+
.insert("X-Test".to_string(), "test".to_string());
302+
303+
// Serialize
304+
let json = serde_json::to_string(&server).expect("Failed to serialize");
305+
306+
// Deserialize
307+
let deserialized: InstalledServer =
308+
serde_json::from_str(&json).expect("Failed to deserialize");
309+
310+
assert_eq!(deserialized.env_overrides.get("KEY"), Some(&"value".to_string()));
311+
assert_eq!(deserialized.args_append, vec!["--flag".to_string()]);
312+
assert_eq!(
313+
deserialized.extra_headers.get("X-Test"),
314+
Some(&"test".to_string())
315+
);
316+
}
317+
318+
#[test]
319+
fn test_custom_fields_default_on_deserialize() {
320+
// JSON without custom fields should deserialize with empty defaults
321+
let json = r#"{
322+
"id": "00000000-0000-0000-0000-000000000001",
323+
"space_id": "space_default",
324+
"server_id": "test-server",
325+
"server_name": null,
326+
"cached_definition": null,
327+
"input_values": {},
328+
"enabled": false,
329+
"oauth_connected": false,
330+
"source": {"type": "registry"},
331+
"created_at": "2025-01-01T00:00:00Z",
332+
"updated_at": "2025-01-01T00:00:00Z"
333+
}"#;
334+
335+
let server: InstalledServer = serde_json::from_str(json).expect("Failed to deserialize");
336+
337+
assert!(server.env_overrides.is_empty());
338+
assert!(server.args_append.is_empty());
339+
assert!(server.extra_headers.is_empty());
340+
}
216341
}

0 commit comments

Comments
 (0)