Skip to content

Commit 4352d32

Browse files
authored
Merge pull request modelcontextprotocol#751 from Jeetulsamaiya/main
Add support for custom headers and migrate from legacy auth
2 parents 758d6f7 + e08ce8c commit 4352d32

File tree

12 files changed

+810
-106
lines changed

12 files changed

+810
-106
lines changed

client/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"@radix-ui/react-popover": "^1.1.3",
3434
"@radix-ui/react-select": "^2.1.2",
3535
"@radix-ui/react-slot": "^1.1.0",
36+
"@radix-ui/react-switch": "^1.2.6",
3637
"@radix-ui/react-tabs": "^1.1.1",
3738
"@radix-ui/react-toast": "^1.2.6",
3839
"@radix-ui/react-tooltip": "^1.1.8",
@@ -69,6 +70,7 @@
6970
"globals": "^15.9.0",
7071
"jest": "^29.7.0",
7172
"jest-environment-jsdom": "^29.7.0",
73+
"jest-fixed-jsdom": "^0.0.9",
7274
"postcss": "^8.5.6",
7375
"tailwindcss": "^3.4.13",
7476
"tailwindcss-animate": "^1.0.7",

client/src/App.tsx

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ import ElicitationTab, {
7474
PendingElicitationRequest,
7575
ElicitationResponse,
7676
} from "./components/ElicitationTab";
77+
import {
78+
CustomHeaders,
79+
migrateFromLegacyAuth,
80+
} from "./lib/types/customHeaders";
7781

7882
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
7983

@@ -127,6 +131,39 @@ const App = () => {
127131
return localStorage.getItem("lastOauthScope") || "";
128132
});
129133

134+
// Custom headers state with migration from legacy auth
135+
const [customHeaders, setCustomHeaders] = useState<CustomHeaders>(() => {
136+
const savedHeaders = localStorage.getItem("lastCustomHeaders");
137+
if (savedHeaders) {
138+
try {
139+
return JSON.parse(savedHeaders);
140+
} catch (error) {
141+
console.warn(
142+
`Failed to parse custom headers: "${savedHeaders}", will try legacy migration`,
143+
error,
144+
);
145+
// Fall back to migration if JSON parsing fails
146+
}
147+
}
148+
149+
// Migrate from legacy auth if available
150+
const legacyToken = localStorage.getItem("lastBearerToken") || "";
151+
const legacyHeaderName = localStorage.getItem("lastHeaderName") || "";
152+
153+
if (legacyToken) {
154+
return migrateFromLegacyAuth(legacyToken, legacyHeaderName);
155+
}
156+
157+
// Default to Authorization: Bearer as the most common case
158+
return [
159+
{
160+
name: "Authorization",
161+
value: "Bearer ",
162+
enabled: true,
163+
},
164+
];
165+
});
166+
130167
const [pendingSampleRequests, setPendingSampleRequests] = useState<
131168
Array<
132169
PendingRequest & {
@@ -213,8 +250,7 @@ const App = () => {
213250
args,
214251
sseUrl,
215252
env,
216-
bearerToken,
217-
headerName,
253+
customHeaders,
218254
oauthClientId,
219255
oauthScope,
220256
config,
@@ -303,13 +339,38 @@ const App = () => {
303339
}, [transportType]);
304340

305341
useEffect(() => {
306-
localStorage.setItem("lastBearerToken", bearerToken);
342+
if (bearerToken) {
343+
localStorage.setItem("lastBearerToken", bearerToken);
344+
} else {
345+
localStorage.removeItem("lastBearerToken");
346+
}
307347
}, [bearerToken]);
308348

309349
useEffect(() => {
310-
localStorage.setItem("lastHeaderName", headerName);
350+
if (headerName) {
351+
localStorage.setItem("lastHeaderName", headerName);
352+
} else {
353+
localStorage.removeItem("lastHeaderName");
354+
}
311355
}, [headerName]);
312356

357+
useEffect(() => {
358+
localStorage.setItem("lastCustomHeaders", JSON.stringify(customHeaders));
359+
}, [customHeaders]);
360+
361+
// Auto-migrate from legacy auth when custom headers are empty but legacy auth exists
362+
useEffect(() => {
363+
if (customHeaders.length === 0 && (bearerToken || headerName)) {
364+
const migratedHeaders = migrateFromLegacyAuth(bearerToken, headerName);
365+
if (migratedHeaders.length > 0) {
366+
setCustomHeaders(migratedHeaders);
367+
// Clear legacy auth after migration
368+
setBearerToken("");
369+
setHeaderName("");
370+
}
371+
}
372+
}, [bearerToken, headerName, customHeaders, setCustomHeaders]);
373+
313374
useEffect(() => {
314375
localStorage.setItem("lastOauthClientId", oauthClientId);
315376
}, [oauthClientId]);
@@ -810,10 +871,8 @@ const App = () => {
810871
setEnv={setEnv}
811872
config={config}
812873
setConfig={setConfig}
813-
bearerToken={bearerToken}
814-
setBearerToken={setBearerToken}
815-
headerName={headerName}
816-
setHeaderName={setHeaderName}
874+
customHeaders={customHeaders}
875+
setCustomHeaders={setCustomHeaders}
817876
oauthClientId={oauthClientId}
818877
setOauthClientId={setOauthClientId}
819878
oauthScope={oauthScope}
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import { useState } from "react";
2+
import { Button } from "@/components/ui/button";
3+
import { Input } from "@/components/ui/input";
4+
import { Textarea } from "@/components/ui/textarea";
5+
import { Switch } from "@/components/ui/switch";
6+
import { Plus, Trash2, Eye, EyeOff } from "lucide-react";
7+
import {
8+
CustomHeaders as CustomHeadersType,
9+
CustomHeader,
10+
createEmptyHeader,
11+
} from "@/lib/types/customHeaders";
12+
13+
interface CustomHeadersProps {
14+
headers: CustomHeadersType;
15+
onChange: (headers: CustomHeadersType) => void;
16+
className?: string;
17+
}
18+
19+
const CustomHeaders = ({
20+
headers,
21+
onChange,
22+
className,
23+
}: CustomHeadersProps) => {
24+
const [isJsonMode, setIsJsonMode] = useState(false);
25+
const [jsonValue, setJsonValue] = useState("");
26+
const [jsonError, setJsonError] = useState<string | null>(null);
27+
const [visibleValues, setVisibleValues] = useState<Set<number>>(new Set());
28+
29+
const updateHeader = (
30+
index: number,
31+
field: keyof CustomHeader,
32+
value: string | boolean,
33+
) => {
34+
const newHeaders = [...headers];
35+
newHeaders[index] = { ...newHeaders[index], [field]: value };
36+
onChange(newHeaders);
37+
};
38+
39+
const addHeader = () => {
40+
onChange([...headers, createEmptyHeader()]);
41+
};
42+
43+
const removeHeader = (index: number) => {
44+
const newHeaders = headers.filter((_, i) => i !== index);
45+
onChange(newHeaders);
46+
};
47+
48+
const toggleValueVisibility = (index: number) => {
49+
const newVisible = new Set(visibleValues);
50+
if (newVisible.has(index)) {
51+
newVisible.delete(index);
52+
} else {
53+
newVisible.add(index);
54+
}
55+
setVisibleValues(newVisible);
56+
};
57+
58+
const switchToJsonMode = () => {
59+
const jsonObject: Record<string, string> = {};
60+
headers.forEach((header) => {
61+
if (header.enabled && header.name.trim() && header.value.trim()) {
62+
jsonObject[header.name.trim()] = header.value.trim();
63+
}
64+
});
65+
setJsonValue(JSON.stringify(jsonObject, null, 2));
66+
setJsonError(null);
67+
setIsJsonMode(true);
68+
};
69+
70+
const switchToFormMode = () => {
71+
try {
72+
const parsed = JSON.parse(jsonValue);
73+
if (
74+
typeof parsed !== "object" ||
75+
parsed === null ||
76+
Array.isArray(parsed)
77+
) {
78+
setJsonError("JSON must be an object with string key-value pairs");
79+
return;
80+
}
81+
82+
const newHeaders: CustomHeadersType = Object.entries(parsed).map(
83+
([name, value]) => ({
84+
name,
85+
value: String(value),
86+
enabled: true,
87+
}),
88+
);
89+
90+
onChange(newHeaders);
91+
setJsonError(null);
92+
setIsJsonMode(false);
93+
} catch {
94+
setJsonError("Invalid JSON format");
95+
}
96+
};
97+
98+
const handleJsonChange = (value: string) => {
99+
setJsonValue(value);
100+
setJsonError(null);
101+
};
102+
103+
if (isJsonMode) {
104+
return (
105+
<div className={`space-y-3 ${className}`}>
106+
<div className="flex justify-between items-center gap-2">
107+
<h4 className="text-sm font-semibold flex-shrink-0">
108+
Custom Headers (JSON)
109+
</h4>
110+
<Button
111+
type="button"
112+
variant="outline"
113+
size="sm"
114+
onClick={switchToFormMode}
115+
className="flex-shrink-0"
116+
>
117+
Switch to Form
118+
</Button>
119+
</div>
120+
<div className="space-y-2">
121+
<Textarea
122+
value={jsonValue}
123+
onChange={(e) => handleJsonChange(e.target.value)}
124+
placeholder='{\n "Authorization": "Bearer token123",\n "X-Tenant-ID": "acme-inc",\n "X-Environment": "staging"\n}'
125+
className="font-mono text-sm min-h-[100px] resize-none"
126+
/>
127+
{jsonError && <p className="text-sm text-red-600">{jsonError}</p>}
128+
<p className="text-xs text-muted-foreground">
129+
Enter headers as a JSON object with string key-value pairs.
130+
</p>
131+
</div>
132+
</div>
133+
);
134+
}
135+
136+
return (
137+
<div className={`space-y-3 ${className}`}>
138+
<div className="flex justify-between items-center gap-2">
139+
<h4 className="text-sm font-semibold flex-shrink-0">Custom Headers</h4>
140+
<div className="flex gap-1 flex-shrink-0">
141+
<Button
142+
type="button"
143+
variant="outline"
144+
size="sm"
145+
onClick={switchToJsonMode}
146+
className="text-xs px-2"
147+
>
148+
JSON
149+
</Button>
150+
<Button
151+
type="button"
152+
variant="outline"
153+
size="sm"
154+
onClick={addHeader}
155+
className="text-xs px-2"
156+
data-testid="add-header-button"
157+
>
158+
<Plus className="w-3 h-3 mr-1" />
159+
Add
160+
</Button>
161+
</div>
162+
</div>
163+
164+
{headers.length === 0 ? (
165+
<div className="text-center py-4 text-muted-foreground">
166+
<p className="text-sm">No custom headers configured</p>
167+
<p className="text-xs mt-1">Click "Add" to get started</p>
168+
</div>
169+
) : (
170+
<div className="space-y-2 max-h-[300px] overflow-y-auto">
171+
{headers.map((header, index) => (
172+
<div
173+
key={index}
174+
className="flex items-start gap-2 p-2 border rounded-md"
175+
>
176+
<Switch
177+
checked={header.enabled}
178+
onCheckedChange={(enabled) =>
179+
updateHeader(index, "enabled", enabled)
180+
}
181+
className="shrink-0 mt-2"
182+
/>
183+
<div className="flex-1 min-w-0 space-y-2">
184+
<Input
185+
placeholder="Header Name"
186+
value={header.name}
187+
onChange={(e) => updateHeader(index, "name", e.target.value)}
188+
className="font-mono text-xs"
189+
data-testid={`header-name-input-${index}`}
190+
/>
191+
<div className="relative">
192+
<Input
193+
placeholder="Header Value"
194+
value={header.value}
195+
onChange={(e) =>
196+
updateHeader(index, "value", e.target.value)
197+
}
198+
type={visibleValues.has(index) ? "text" : "password"}
199+
className="font-mono text-xs pr-8"
200+
data-testid={`header-value-input-${index}`}
201+
/>
202+
<Button
203+
type="button"
204+
variant="ghost"
205+
size="sm"
206+
onClick={() => toggleValueVisibility(index)}
207+
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6 p-0"
208+
>
209+
{visibleValues.has(index) ? (
210+
<EyeOff className="w-3 h-3" />
211+
) : (
212+
<Eye className="w-3 h-3" />
213+
)}
214+
</Button>
215+
</div>
216+
</div>
217+
<Button
218+
type="button"
219+
variant="ghost"
220+
size="sm"
221+
onClick={() => removeHeader(index)}
222+
className="shrink-0 text-red-600 hover:text-red-700 hover:bg-red-50 h-6 w-6 p-0 mt-2"
223+
>
224+
<Trash2 className="w-3 h-3" />
225+
</Button>
226+
</div>
227+
))}
228+
</div>
229+
)}
230+
231+
{headers.length > 0 && (
232+
<p className="text-xs text-muted-foreground">
233+
Use the toggle to enable/disable headers. Only enabled headers with
234+
both name and value will be sent.
235+
</p>
236+
)}
237+
</div>
238+
);
239+
};
240+
241+
export default CustomHeaders;

0 commit comments

Comments
 (0)