Merge branch 'main' into fix-214

This commit is contained in:
Cliff Hall
2025-03-29 12:34:20 -04:00
committed by GitHub
10 changed files with 330 additions and 111 deletions

View File

@@ -52,16 +52,6 @@ const PROXY_SERVER_URL = `http://${window.location.hostname}:${PROXY_PORT}`;
const App = () => { const App = () => {
// Handle OAuth callback route // Handle OAuth callback route
if (window.location.pathname === "/oauth/callback") {
const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"),
);
return (
<Suspense fallback={<div>Loading...</div>}>
<OAuthCallback />
</Suspense>
);
}
const [resources, setResources] = useState<Resource[]>([]); const [resources, setResources] = useState<Resource[]>([]);
const [resourceTemplates, setResourceTemplates] = useState< const [resourceTemplates, setResourceTemplates] = useState<
ResourceTemplate[] ResourceTemplate[]
@@ -114,22 +104,6 @@ const App = () => {
const nextRequestId = useRef(0); const nextRequestId = useRef(0);
const rootsRef = useRef<Root[]>([]); const rootsRef = useRef<Root[]>([]);
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.resolve(result);
return prev.filter((r) => r.id !== id);
});
};
const handleRejectSampling = (id: number) => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.reject(new Error("Sampling request rejected"));
return prev.filter((r) => r.id !== id);
});
};
const [selectedResource, setSelectedResource] = useState<Resource | null>( const [selectedResource, setSelectedResource] = useState<Resource | null>(
null, null,
); );
@@ -224,7 +198,7 @@ const App = () => {
// Connect to the server // Connect to the server
connectMcpServer(); connectMcpServer();
} }
}, []); }, [connectMcpServer]);
useEffect(() => { useEffect(() => {
fetch(`${PROXY_SERVER_URL}/config`) fetch(`${PROXY_SERVER_URL}/config`)
@@ -253,6 +227,22 @@ const App = () => {
} }
}, []); }, []);
const handleApproveSampling = (id: number, result: CreateMessageResult) => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.resolve(result);
return prev.filter((r) => r.id !== id);
});
};
const handleRejectSampling = (id: number) => {
setPendingSampleRequests((prev) => {
const request = prev.find((r) => r.id === id);
request?.reject(new Error("Sampling request rejected"));
return prev.filter((r) => r.id !== id);
});
};
const clearError = (tabKey: keyof typeof errors) => { const clearError = (tabKey: keyof typeof errors) => {
setErrors((prev) => ({ ...prev, [tabKey]: null })); setErrors((prev) => ({ ...prev, [tabKey]: null }));
}; };
@@ -425,6 +415,17 @@ const App = () => {
setLogLevel(level); setLogLevel(level);
}; };
if (window.location.pathname === "/oauth/callback") {
const OAuthCallback = React.lazy(
() => import("./components/OAuthCallback"),
);
return (
<Suspense fallback={<div>Loading...</div>}>
<OAuthCallback />
</Suspense>
);
}
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
<Sidebar <Sidebar

View File

@@ -108,6 +108,21 @@ const DynamicJsonForm = ({
} }
}; };
const formatJson = () => {
try {
const jsonStr = rawJsonValue.trim();
if (!jsonStr) {
return;
}
const formatted = JSON.stringify(JSON.parse(jsonStr), null, 2);
setRawJsonValue(formatted);
debouncedUpdateParent(formatted);
setJsonError(undefined);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
};
const renderFormFields = ( const renderFormFields = (
propSchema: JsonSchemaType, propSchema: JsonSchemaType,
currentValue: JsonValue, currentValue: JsonValue,
@@ -353,7 +368,12 @@ const DynamicJsonForm = ({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-end"> <div className="flex justify-end space-x-2">
{isJsonMode && (
<Button variant="outline" size="sm" onClick={formatJson}>
Format JSON
</Button>
)}
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}> <Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
{isJsonMode ? "Switch to Form" : "Switch to JSON"} {isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button> </Button>

View File

@@ -1,6 +1,7 @@
import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import { ServerNotification } from "@modelcontextprotocol/sdk/types.js";
import { Copy } from "lucide-react"; import { Copy } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import JsonView from "./JsonView";
const HistoryAndNotifications = ({ const HistoryAndNotifications = ({
requestHistory, requestHistory,
@@ -74,9 +75,9 @@ const HistoryAndNotifications = ({
<Copy size={16} /> <Copy size={16} />
</button> </button>
</div> </div>
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded"> <div className="bg-background p-2 rounded">
{JSON.stringify(JSON.parse(request.request), null, 2)} <JsonView data={request.request} />
</pre> </div>
</div> </div>
{request.response && ( {request.response && (
<div className="mt-2"> <div className="mt-2">
@@ -91,13 +92,9 @@ const HistoryAndNotifications = ({
<Copy size={16} /> <Copy size={16} />
</button> </button>
</div> </div>
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded"> <div className="bg-background p-2 rounded">
{JSON.stringify( <JsonView data={request.response} />
JSON.parse(request.response), </div>
null,
2,
)}
</pre>
</div> </div>
)} )}
</> </>
@@ -146,9 +143,11 @@ const HistoryAndNotifications = ({
<Copy size={16} /> <Copy size={16} />
</button> </button>
</div> </div>
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded"> <div className="bg-background p-2 rounded">
{JSON.stringify(notification, null, 2)} <JsonView
</pre> data={JSON.stringify(notification, null, 2)}
/>
</div>
</div> </div>
)} )}
</li> </li>

View File

@@ -3,7 +3,6 @@ import Editor from "react-simple-code-editor";
import Prism from "prismjs"; import Prism from "prismjs";
import "prismjs/components/prism-json"; import "prismjs/components/prism-json";
import "prismjs/themes/prism.css"; import "prismjs/themes/prism.css";
import { Button } from "@/components/ui/button";
interface JsonEditorProps { interface JsonEditorProps {
value: string; value: string;
@@ -16,49 +15,25 @@ const JsonEditor = ({
onChange, onChange,
error: externalError, error: externalError,
}: JsonEditorProps) => { }: JsonEditorProps) => {
const [editorContent, setEditorContent] = useState(value); const [editorContent, setEditorContent] = useState(value || "");
const [internalError, setInternalError] = useState<string | undefined>( const [internalError, setInternalError] = useState<string | undefined>(
undefined, undefined,
); );
useEffect(() => { useEffect(() => {
setEditorContent(value); setEditorContent(value || "");
}, [value]); }, [value]);
const formatJson = (json: string): string => {
try {
return JSON.stringify(JSON.parse(json), null, 2);
} catch {
return json;
}
};
const handleEditorChange = (newContent: string) => { const handleEditorChange = (newContent: string) => {
setEditorContent(newContent); setEditorContent(newContent);
setInternalError(undefined); setInternalError(undefined);
onChange(newContent); onChange(newContent);
}; };
const handleFormatJson = () => {
try {
const formatted = formatJson(editorContent);
setEditorContent(formatted);
onChange(formatted);
setInternalError(undefined);
} catch (err) {
setInternalError(err instanceof Error ? err.message : "Invalid JSON");
}
};
const displayError = internalError || externalError; const displayError = internalError || externalError;
return ( return (
<div className="relative space-y-2"> <div className="relative">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={handleFormatJson}>
Format JSON
</Button>
</div>
<div <div
className={`border rounded-md ${ className={`border rounded-md ${
displayError displayError

View File

@@ -0,0 +1,228 @@
import { useState, memo } from "react";
import { JsonValue } from "./DynamicJsonForm";
import clsx from "clsx";
interface JsonViewProps {
data: unknown;
name?: string;
initialExpandDepth?: number;
}
function tryParseJson(str: string): { success: boolean; data: JsonValue } {
const trimmed = str.trim();
if (
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
return { success: false, data: str };
}
try {
return { success: true, data: JSON.parse(str) };
} catch {
return { success: false, data: str };
}
}
const JsonView = memo(
({ data, name, initialExpandDepth = 3 }: JsonViewProps) => {
const normalizedData =
typeof data === "string"
? tryParseJson(data).success
? tryParseJson(data).data
: data
: data;
return (
<div className="font-mono text-sm transition-all duration-300 ">
<JsonNode
data={normalizedData as JsonValue}
name={name}
depth={0}
initialExpandDepth={initialExpandDepth}
/>
</div>
);
},
);
JsonView.displayName = "JsonView";
interface JsonNodeProps {
data: JsonValue;
name?: string;
depth: number;
initialExpandDepth: number;
}
const JsonNode = memo(
({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => {
const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth);
const getDataType = (value: JsonValue): string => {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
};
const dataType = getDataType(data);
const typeStyleMap: Record<string, string> = {
number: "text-blue-600",
boolean: "text-amber-600",
null: "text-purple-600",
undefined: "text-gray-600",
string: "text-green-600 break-all whitespace-pre-wrap",
default: "text-gray-700",
};
const renderCollapsible = (isArray: boolean) => {
const items = isArray
? (data as JsonValue[])
: Object.entries(data as Record<string, JsonValue>);
const itemCount = items.length;
const isEmpty = itemCount === 0;
const symbolMap = {
open: isArray ? "[" : "{",
close: isArray ? "]" : "}",
collapsed: isArray ? "[ ... ]" : "{ ... }",
empty: isArray ? "[]" : "{}",
};
if (isEmpty) {
return (
<div className="flex items-center">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
</span>
)}
<span className="text-gray-500">{symbolMap.empty}</span>
</div>
);
}
return (
<div className="flex flex-col">
<div
className="flex items-center mr-1 rounded cursor-pointer group hover:bg-gray-800/10 dark:hover:bg-gray-800/20"
onClick={() => setIsExpanded(!isExpanded)}
>
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{name}:
</span>
)}
{isExpanded ? (
<span className="text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{symbolMap.open}
</span>
) : (
<>
<span className="text-gray-600 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{symbolMap.collapsed}
</span>
<span className="ml-1 text-gray-700 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{itemCount} {itemCount === 1 ? "item" : "items"}
</span>
</>
)}
</div>
{isExpanded && (
<>
<div className="pl-2 ml-4 border-l border-gray-200 dark:border-gray-800">
{isArray
? (items as JsonValue[]).map((item, index) => (
<div key={index} className="my-1">
<JsonNode
data={item}
name={`${index}`}
depth={depth + 1}
initialExpandDepth={initialExpandDepth}
/>
</div>
))
: (items as [string, JsonValue][]).map(([key, value]) => (
<div key={key} className="my-1">
<JsonNode
data={value}
name={key}
depth={depth + 1}
initialExpandDepth={initialExpandDepth}
/>
</div>
))}
</div>
<div className="text-gray-600 dark:text-gray-400">
{symbolMap.close}
</div>
</>
)}
</div>
);
};
const renderString = (value: string) => {
const maxLength = 100;
const isTooLong = value.length > maxLength;
if (!isTooLong) {
return (
<div className="flex mr-1 rounded hover:bg-gray-800/20">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
</span>
)}
<pre className={typeStyleMap.string}>"{value}"</pre>
</div>
);
}
return (
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400">
{name}:
</span>
)}
<pre
className={clsx(
typeStyleMap.string,
"cursor-pointer group-hover:text-green-500",
)}
onClick={() => setIsExpanded(!isExpanded)}
title={isExpanded ? "Click to collapse" : "Click to expand"}
>
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
</pre>
</div>
);
};
switch (dataType) {
case "object":
case "array":
return renderCollapsible(dataType === "array");
case "string":
return renderString(data as string);
default:
return (
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
{name && (
<span className="mr-1 text-gray-600 dark:text-gray-400">
{name}:
</span>
)}
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
{data === null ? "null" : String(data)}
</span>
</div>
);
}
},
);
JsonNode.displayName = "JsonNode";
export default JsonView;

View File

@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox"; import { Combobox } from "@/components/ui/combobox";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { import {
ListPromptsResult, ListPromptsResult,
PromptReference, PromptReference,
@@ -13,6 +13,7 @@ import { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ListPane from "./ListPane"; import ListPane from "./ListPane";
import { useCompletionState } from "@/lib/hooks/useCompletionState"; import { useCompletionState } from "@/lib/hooks/useCompletionState";
import JsonView from "./JsonView";
export type Prompt = { export type Prompt = {
name: string; name: string;
@@ -151,11 +152,9 @@ const PromptsTab = ({
Get Prompt Get Prompt
</Button> </Button>
{promptContent && ( {promptContent && (
<Textarea <div className="p-4 border rounded">
value={promptContent} <JsonView data={promptContent} />
readOnly </div>
className="h-64 font-mono"
/>
)} )}
</div> </div>
) : ( ) : (

View File

@@ -15,6 +15,7 @@ import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react";
import ListPane from "./ListPane"; import ListPane from "./ListPane";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useCompletionState } from "@/lib/hooks/useCompletionState"; import { useCompletionState } from "@/lib/hooks/useCompletionState";
import JsonView from "./JsonView";
const ResourcesTab = ({ const ResourcesTab = ({
resources, resources,
@@ -214,9 +215,9 @@ const ResourcesTab = ({
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
) : selectedResource ? ( ) : selectedResource ? (
<pre className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 whitespace-pre-wrap break-words text-gray-900 dark:text-gray-100"> <div className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 text-gray-900 dark:text-gray-100">
{resourceContent} <JsonView data={resourceContent} />
</pre> </div>
) : selectedTemplate ? ( ) : selectedTemplate ? (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">

View File

@@ -5,6 +5,7 @@ import {
CreateMessageRequest, CreateMessageRequest,
CreateMessageResult, CreateMessageResult,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import JsonView from "./JsonView";
export type PendingRequest = { export type PendingRequest = {
id: number; id: number;
@@ -43,9 +44,9 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
<h3 className="text-lg font-semibold">Recent Requests</h3> <h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => ( {pendingRequests.map((request) => (
<div key={request.id} className="p-4 border rounded-lg space-y-4"> <div key={request.id} className="p-4 border rounded-lg space-y-4">
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded"> <div className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
{JSON.stringify(request.request, null, 2)} <JsonView data={JSON.stringify(request.request)} />
</pre> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button onClick={() => handleApprove(request.id)}>Approve</Button> <Button onClick={() => handleApprove(request.id)}>Approve</Button>
<Button variant="outline" onClick={() => onReject(request.id)}> <Button variant="outline" onClick={() => onReject(request.id)}>

View File

@@ -16,7 +16,7 @@ import {
import { AlertCircle, Send } from "lucide-react"; import { AlertCircle, Send } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import ListPane from "./ListPane"; import ListPane from "./ListPane";
import { escapeUnicode } from "@/utils/escapeUnicode"; import JsonView from "./JsonView";
const ToolsTab = ({ const ToolsTab = ({
tools, tools,
@@ -53,17 +53,14 @@ const ToolsTab = ({
return ( return (
<> <>
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4> <h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"> <div className="p-4 border rounded">
{escapeUnicode(toolResult)} <JsonView data={toolResult} />
</pre> </div>
<h4 className="font-semibold mb-2">Errors:</h4> <h4 className="font-semibold mb-2">Errors:</h4>
{parsedResult.error.errors.map((error, idx) => ( {parsedResult.error.errors.map((error, idx) => (
<pre <div key={idx} className="p-4 border rounded">
key={idx} <JsonView data={error} />
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64" </div>
>
{escapeUnicode(error)}
</pre>
))} ))}
</> </>
); );
@@ -79,9 +76,9 @@ const ToolsTab = ({
{structuredResult.content.map((item, index) => ( {structuredResult.content.map((item, index) => (
<div key={index} className="mb-2"> <div key={index} className="mb-2">
{item.type === "text" && ( {item.type === "text" && (
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"> <div className="p-4 border rounded">
{item.text} <JsonView data={item.text} />
</pre> </div>
)} )}
{item.type === "image" && ( {item.type === "image" && (
<img <img
@@ -100,9 +97,9 @@ const ToolsTab = ({
<p>Your browser does not support audio playback</p> <p>Your browser does not support audio playback</p>
</audio> </audio>
) : ( ) : (
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64"> <div className="p-4 border rounded">
{escapeUnicode(item.resource)} <JsonView data={item.resource} />
</pre> </div>
))} ))}
</div> </div>
))} ))}
@@ -112,9 +109,9 @@ const ToolsTab = ({
return ( return (
<> <>
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4> <h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64"> <div className="p-4 border rounded">
{escapeUnicode(toolResult.toolResult)} <JsonView data={toolResult.toolResult} />
</pre> </div>
</> </>
); );
} }

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
type Theme = "light" | "dark" | "system"; type Theme = "light" | "dark" | "system";
@@ -36,16 +36,14 @@ const useTheme = (): [Theme, (mode: Theme) => void] => {
}; };
}, [theme]); }, [theme]);
return [ const setThemeWithSideEffect = useCallback((newTheme: Theme) => {
theme, setTheme(newTheme);
useCallback((newTheme: Theme) => { localStorage.setItem("theme", newTheme);
setTheme(newTheme); if (newTheme !== "system") {
localStorage.setItem("theme", newTheme); document.documentElement.classList.toggle("dark", newTheme === "dark");
if (newTheme !== "system") { }
document.documentElement.classList.toggle("dark", newTheme === "dark"); }, []);
} return useMemo(() => [theme, setThemeWithSideEffect], [theme]);
}, []),
];
}; };
export default useTheme; export default useTheme;