Merge branch 'main' into feat/proxyServerUrl

This commit is contained in:
Larmyliu
2025-03-23 00:11:58 +08:00
committed by GitHub
16 changed files with 4169 additions and 193 deletions

View File

@@ -25,6 +25,11 @@ jobs:
# Working around https://github.com/npm/cli/issues/4828 # Working around https://github.com/npm/cli/issues/4828
# - run: npm ci # - run: npm ci
- run: npm install --no-package-lock - run: npm install --no-package-lock
- name: Run client tests
working-directory: ./client
run: npm test
- run: npm run build - run: npm run build
publish: publish:

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ server/build
client/dist client/dist
client/tsconfig.app.tsbuildinfo client/tsconfig.app.tsbuildinfo
client/tsconfig.node.tsbuildinfo client/tsconfig.node.tsbuildinfo
.vscode

View File

@@ -27,9 +27,15 @@ async function main() {
} }
if (parsingFlags && arg === "-e" && i + 1 < args.length) { if (parsingFlags && arg === "-e" && i + 1 < args.length) {
const [key, value] = args[++i].split("="); const envVar = args[++i];
if (key && value) { const equalsIndex = envVar.indexOf("=");
if (equalsIndex !== -1) {
const key = envVar.substring(0, equalsIndex);
const value = envVar.substring(equalsIndex + 1);
envVars[key] = value; envVars[key] = value;
} else {
envVars[envVar] = "";
} }
} else if (!command) { } else if (!command) {
command = arg; command = arg;

37
client/jest.config.cjs Normal file
View File

@@ -0,0 +1,37 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"^../components/DynamicJsonForm$":
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
"^../../components/DynamicJsonForm$":
"<rootDir>/src/utils/__mocks__/DynamicJsonForm.ts",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
jsx: "react-jsx",
tsconfig: "tsconfig.jest.json",
},
],
},
extensionsToTreatAsEsm: [".ts", ".tsx"],
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
// Exclude directories and files that don't need to be tested
testPathIgnorePatterns: [
"/node_modules/",
"/dist/",
"/bin/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
// Exclude the same patterns from coverage reports
coveragePathIgnorePatterns: [
"/node_modules/",
"/dist/",
"/bin/",
"\\.config\\.(js|ts|cjs|mjs)$",
],
};

View File

@@ -18,7 +18,9 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "jest --config jest.config.cjs",
"test:watch": "jest --config jest.config.cjs --watch"
}, },
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1", "@modelcontextprotocol/sdk": "^1.6.1",
@@ -35,8 +37,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"lucide-react": "^0.447.0", "lucide-react": "^0.447.0",
"prismjs": "^1.29.0",
"pkce-challenge": "^4.1.0", "pkce-challenge": "^4.1.0",
"prismjs": "^1.29.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
@@ -48,18 +50,23 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.11.1", "@eslint/js": "^9.11.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.7.5", "@types/node": "^22.7.5",
"@types/react": "^18.3.10", "@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/serve-handler": "^6.1.4", "@types/serve-handler": "^6.1.4",
"@vitejs/plugin-react": "^4.3.2", "@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"co": "^4.6.0",
"eslint": "^9.11.1", "eslint": "^9.11.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.12", "eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.9.0", "globals": "^15.9.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"ts-jest": "^29.2.6",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.7.0", "typescript-eslint": "^8.7.0",
"vite": "^5.4.8" "vite": "^5.4.8"

View File

@@ -15,6 +15,7 @@ import {
Root, Root,
ServerNotification, ServerNotification,
Tool, Tool,
LoggingLevel,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import React, { Suspense, useEffect, useRef, useState } from "react"; import React, { Suspense, useEffect, useRef, useState } from "react";
import { useConnection } from "./lib/hooks/useConnection"; import { useConnection } from "./lib/hooks/useConnection";
@@ -91,6 +92,7 @@ const App = () => {
(localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio" (localStorage.getItem("lastTransportType") as "stdio" | "sse") || "stdio"
); );
}); });
const [logLevel, setLogLevel] = useState<LoggingLevel>("debug");
const [notifications, setNotifications] = useState<ServerNotification[]>([]); const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const [stdErrNotifications, setStdErrNotifications] = useState< const [stdErrNotifications, setStdErrNotifications] = useState<
StdErrNotification[] StdErrNotification[]
@@ -412,6 +414,17 @@ const App = () => {
await sendNotification({ method: "notifications/roots/list_changed" }); await sendNotification({ method: "notifications/roots/list_changed" });
}; };
const sendLogLevelRequest = async (level: LoggingLevel) => {
await makeRequest(
{
method: "logging/setLevel" as const,
params: { level },
},
z.object({}),
);
setLogLevel(level);
};
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
<Sidebar <Sidebar
@@ -430,6 +443,9 @@ const App = () => {
setBearerToken={setBearerToken} setBearerToken={setBearerToken}
onConnect={connectMcpServer} onConnect={connectMcpServer}
stdErrNotifications={stdErrNotifications} stdErrNotifications={stdErrNotifications}
logLevel={logLevel}
sendLogLevelRequest={sendLogLevelRequest}
loggingSupported={!!serverCapabilities?.logging || false}
/> />
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">

View File

@@ -1,26 +1,36 @@
import { useState } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import JsonEditor from "./JsonEditor"; import JsonEditor from "./JsonEditor";
import { updateValueAtPath, JsonObject } from "@/utils/jsonPathUtils";
import { generateDefaultValue, formatFieldLabel } from "@/utils/schemaUtils";
export type JsonValue = export type JsonValue =
| string | string
| number | number
| boolean | boolean
| null | null
| undefined
| JsonValue[] | JsonValue[]
| { [key: string]: JsonValue }; | { [key: string]: JsonValue };
export type JsonSchemaType = { export type JsonSchemaType = {
type: "string" | "number" | "integer" | "boolean" | "array" | "object"; type:
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null";
description?: string; description?: string;
required?: boolean;
default?: JsonValue;
properties?: Record<string, JsonSchemaType>; properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType; items?: JsonSchemaType;
}; };
type JsonObject = { [key: string]: JsonValue };
interface DynamicJsonFormProps { interface DynamicJsonFormProps {
schema: JsonSchemaType; schema: JsonSchemaType;
value: JsonValue; value: JsonValue;
@@ -28,13 +38,6 @@ interface DynamicJsonFormProps {
maxDepth?: number; maxDepth?: number;
} }
const formatFieldLabel = (key: string): string => {
return key
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
.replace(/_/g, " ") // Replace underscores with spaces
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
};
const DynamicJsonForm = ({ const DynamicJsonForm = ({
schema, schema,
value, value,
@@ -43,29 +46,65 @@ const DynamicJsonForm = ({
}: DynamicJsonFormProps) => { }: DynamicJsonFormProps) => {
const [isJsonMode, setIsJsonMode] = useState(false); const [isJsonMode, setIsJsonMode] = useState(false);
const [jsonError, setJsonError] = useState<string>(); const [jsonError, setJsonError] = useState<string>();
// Store the raw JSON string to allow immediate feedback during typing
// while deferring parsing until the user stops typing
const [rawJsonValue, setRawJsonValue] = useState<string>(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
const generateDefaultValue = (propSchema: JsonSchemaType): JsonValue => { // Use a ref to manage debouncing timeouts to avoid parsing JSON
switch (propSchema.type) { // on every keystroke which would be inefficient and error-prone
case "string": const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
return "";
case "number": // Debounce JSON parsing and parent updates to handle typing gracefully
case "integer": const debouncedUpdateParent = useCallback(
return 0; (jsonString: string) => {
case "boolean": // Clear any existing timeout
return false; if (timeoutRef.current) {
case "array": clearTimeout(timeoutRef.current);
return [];
case "object": {
const obj: JsonObject = {};
if (propSchema.properties) {
Object.entries(propSchema.properties).forEach(([key, prop]) => {
obj[key] = generateDefaultValue(prop);
});
} }
return obj;
// Set a new timeout
timeoutRef.current = setTimeout(() => {
try {
const parsed = JSON.parse(jsonString);
onChange(parsed);
setJsonError(undefined);
} catch {
// Don't set error during normal typing
} }
default: }, 300);
return null; },
[onChange, setJsonError],
);
// Update rawJsonValue when value prop changes
useEffect(() => {
if (!isJsonMode) {
setRawJsonValue(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
}
}, [value, schema, isJsonMode]);
const handleSwitchToFormMode = () => {
if (isJsonMode) {
// When switching to Form mode, ensure we have valid JSON
try {
const parsed = JSON.parse(rawJsonValue);
// Update the parent component's state with the parsed value
onChange(parsed);
// Switch to form mode
setIsJsonMode(false);
} catch (err) {
setJsonError(err instanceof Error ? err.message : "Invalid JSON");
}
} else {
// Update raw JSON value when switching to JSON mode
setRawJsonValue(
JSON.stringify(value ?? generateDefaultValue(schema), null, 2),
);
setIsJsonMode(true);
} }
}; };
@@ -103,21 +142,68 @@ const DynamicJsonForm = ({
switch (propSchema.type) { switch (propSchema.type) {
case "string": case "string":
return (
<Input
type="text"
value={(currentValue as string) ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required fields by setting undefined
// This preserves the distinction between empty string and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
handleFieldChange(path, val);
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "number": case "number":
return (
<Input
type="number"
value={(currentValue as number)?.toString() ?? ""}
onChange={(e) => {
const val = e.target.value;
// Allow clearing non-required number fields
// This preserves the distinction between 0 and unset
if (!val && !propSchema.required) {
handleFieldChange(path, undefined);
} else {
const num = Number(val);
if (!isNaN(num)) {
handleFieldChange(path, num);
}
}
}}
placeholder={propSchema.description}
required={propSchema.required}
/>
);
case "integer": case "integer":
return ( return (
<Input <Input
type={propSchema.type === "string" ? "text" : "number"} type="number"
value={(currentValue as string | number) ?? ""} step="1"
onChange={(e) => value={(currentValue as number)?.toString() ?? ""}
handleFieldChange( onChange={(e) => {
path, const val = e.target.value;
propSchema.type === "string" // Allow clearing non-required integer fields
? e.target.value // This preserves the distinction between 0 and unset
: Number(e.target.value), if (!val && !propSchema.required) {
) handleFieldChange(path, undefined);
} else {
const num = Number(val);
// Only update if it's a valid integer
if (!isNaN(num) && Number.isInteger(num)) {
handleFieldChange(path, num);
} }
}
}}
placeholder={propSchema.description} placeholder={propSchema.description}
required={propSchema.required}
/> />
); );
case "boolean": case "boolean":
@@ -127,10 +213,15 @@ const DynamicJsonForm = ({
checked={(currentValue as boolean) ?? false} checked={(currentValue as boolean) ?? false}
onChange={(e) => handleFieldChange(path, e.target.checked)} onChange={(e) => handleFieldChange(path, e.target.checked)}
className="w-4 h-4" className="w-4 h-4"
required={propSchema.required}
/> />
); );
case "object": case "object": {
if (!propSchema.properties) return null; // Handle case where we have a value but no schema properties
const objectValue = (currentValue as JsonObject) || {};
// If we have schema properties, use them to render fields
if (propSchema.properties) {
return ( return (
<div className="space-y-4 border rounded-md p-4"> <div className="space-y-4 border rounded-md p-4">
{Object.entries(propSchema.properties).map(([key, prop]) => ( {Object.entries(propSchema.properties).map(([key, prop]) => (
@@ -138,7 +229,7 @@ const DynamicJsonForm = ({
<Label>{formatFieldLabel(key)}</Label> <Label>{formatFieldLabel(key)}</Label>
{renderFormFields( {renderFormFields(
prop, prop,
(currentValue as JsonObject)?.[key], objectValue[key],
[...path, key], [...path, key],
depth + 1, depth + 1,
)} )}
@@ -146,6 +237,29 @@ const DynamicJsonForm = ({
))} ))}
</div> </div>
); );
}
// If we have a value but no schema properties, render fields based on the value
else if (Object.keys(objectValue).length > 0) {
return (
<div className="space-y-4 border rounded-md p-4">
{Object.entries(objectValue).map(([key, value]) => (
<div key={key} className="space-y-2">
<Label>{formatFieldLabel(key)}</Label>
<Input
type="text"
value={String(value)}
onChange={(e) =>
handleFieldChange([...path, key], e.target.value)
}
/>
</div>
))}
</div>
);
}
// If we have neither schema properties nor value, return null
return null;
}
case "array": { case "array": {
const arrayValue = Array.isArray(currentValue) ? currentValue : []; const arrayValue = Array.isArray(currentValue) ? currentValue : [];
if (!propSchema.items) return null; if (!propSchema.items) return null;
@@ -187,9 +301,12 @@ const DynamicJsonForm = ({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const defaultValue = generateDefaultValue(
propSchema.items as JsonSchemaType,
);
handleFieldChange(path, [ handleFieldChange(path, [
...arrayValue, ...arrayValue,
generateDefaultValue(propSchema.items as JsonSchemaType), defaultValue ?? null,
]); ]);
}} }}
title={ title={
@@ -215,139 +332,65 @@ const DynamicJsonForm = ({
return; return;
} }
const updateArray = (
array: JsonValue[],
path: string[],
value: JsonValue,
): JsonValue[] => {
const [index, ...restPath] = path;
const arrayIndex = Number(index);
// Validate array index
if (isNaN(arrayIndex)) {
console.error(`Invalid array index: ${index}`);
return array;
}
// Check array bounds
if (arrayIndex < 0) {
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
return array;
}
const newArray = [...array];
if (restPath.length === 0) {
newArray[arrayIndex] = value;
} else {
// Ensure index position exists
if (arrayIndex >= array.length) {
console.warn(`Extending array to index ${arrayIndex}`);
newArray.length = arrayIndex + 1;
newArray.fill(null, array.length, arrayIndex);
}
newArray[arrayIndex] = updateValue(
newArray[arrayIndex],
restPath,
value,
);
}
return newArray;
};
const updateObject = (
obj: JsonObject,
path: string[],
value: JsonValue,
): JsonObject => {
const [key, ...restPath] = path;
// Validate object key
if (typeof key !== "string") {
console.error(`Invalid object key: ${key}`);
return obj;
}
const newObj = { ...obj };
if (restPath.length === 0) {
newObj[key] = value;
} else {
// Ensure key exists
if (!(key in newObj)) {
console.warn(`Creating new key in object: ${key}`);
newObj[key] = {};
}
newObj[key] = updateValue(newObj[key], restPath, value);
}
return newObj;
};
const updateValue = (
current: JsonValue,
path: string[],
value: JsonValue,
): JsonValue => {
if (path.length === 0) return value;
try { try {
if (!current) { const newValue = updateValueAtPath(value, path, fieldValue);
current = !isNaN(Number(path[0])) ? [] : {};
}
// Type checking
if (Array.isArray(current)) {
return updateArray(current, path, value);
} else if (typeof current === "object" && current !== null) {
return updateObject(current, path, value);
} else {
console.error(
`Cannot update path ${path.join(".")} in non-object/array value:`,
current,
);
return current;
}
} catch (error) {
console.error(`Error updating value at path ${path.join(".")}:`, error);
return current;
}
};
try {
const newValue = updateValue(value, path, fieldValue);
onChange(newValue); onChange(newValue);
} catch (error) { } catch (error) {
console.error("Failed to update form value:", error); console.error("Failed to update form value:", error);
// Keep the original value unchanged
onChange(value); onChange(value);
} }
}; };
const shouldUseJsonMode =
schema.type === "object" &&
(!schema.properties || Object.keys(schema.properties).length === 0);
useEffect(() => {
if (shouldUseJsonMode && !isJsonMode) {
setIsJsonMode(true);
}
}, [shouldUseJsonMode, isJsonMode]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
variant="outline"
size="sm"
onClick={() => setIsJsonMode(!isJsonMode)}
>
{isJsonMode ? "Switch to Form" : "Switch to JSON"} {isJsonMode ? "Switch to Form" : "Switch to JSON"}
</Button> </Button>
</div> </div>
{isJsonMode ? ( {isJsonMode ? (
<JsonEditor <JsonEditor
value={JSON.stringify(value ?? generateDefaultValue(schema), null, 2)} value={rawJsonValue}
onChange={(newValue) => { onChange={(newValue) => {
try { // Always update local state
onChange(JSON.parse(newValue)); setRawJsonValue(newValue);
setJsonError(undefined);
} catch (err) { // Use the debounced function to attempt parsing and updating parent
setJsonError(err instanceof Error ? err.message : "Invalid JSON"); debouncedUpdateParent(newValue);
}
}} }}
error={jsonError} error={jsonError}
/> />
) : // If schema type is object but value is not an object or is empty, and we have actual JSON data,
// render a simple representation of the JSON data
schema.type === "object" &&
(typeof value !== "object" ||
value === null ||
Object.keys(value).length === 0) &&
rawJsonValue &&
rawJsonValue !== "{}" ? (
<div className="space-y-4 border rounded-md p-4">
<p className="text-sm text-gray-500">
Form view not available for this JSON structure. Using simplified
view:
</p>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto">
{rawJsonValue}
</pre>
<p className="text-sm text-gray-500">
Use JSON mode for full editing capabilities.
</p>
</div>
) : ( ) : (
renderFormFields(schema, value) renderFormFields(schema, value)
)} )}

View File

@@ -1,3 +1,4 @@
import { useState, useEffect } from "react";
import Editor from "react-simple-code-editor"; 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";
@@ -10,7 +11,20 @@ interface JsonEditorProps {
error?: string; error?: string;
} }
const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => { const JsonEditor = ({
value,
onChange,
error: externalError,
}: JsonEditorProps) => {
const [editorContent, setEditorContent] = useState(value);
const [internalError, setInternalError] = useState<string | undefined>(
undefined,
);
useEffect(() => {
setEditorContent(value);
}, [value]);
const formatJson = (json: string): string => { const formatJson = (json: string): string => {
try { try {
return JSON.stringify(JSON.parse(json), null, 2); return JSON.stringify(JSON.parse(json), null, 2);
@@ -19,25 +33,42 @@ const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
} }
}; };
const handleEditorChange = (newContent: string) => {
setEditorContent(newContent);
setInternalError(undefined);
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;
return ( return (
<div className="relative space-y-2"> <div className="relative space-y-2">
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button variant="outline" size="sm" onClick={handleFormatJson}>
variant="outline"
size="sm"
onClick={() => onChange(formatJson(value))}
>
Format JSON Format JSON
</Button> </Button>
</div> </div>
<div <div
className={`border rounded-md ${ className={`border rounded-md ${
error ? "border-red-500" : "border-gray-200 dark:border-gray-800" displayError
? "border-red-500"
: "border-gray-200 dark:border-gray-800"
}`} }`}
> >
<Editor <Editor
value={value} value={editorContent}
onValueChange={onChange} onValueChange={handleEditorChange}
highlight={(code) => highlight={(code) =>
Prism.highlight(code, Prism.languages.json, "json") Prism.highlight(code, Prism.languages.json, "json")
} }
@@ -51,7 +82,9 @@ const JsonEditor = ({ value, onChange, error }: JsonEditorProps) => {
className="w-full" className="w-full"
/> />
</div> </div>
{error && <p className="text-sm text-red-500 mt-1">{error}</p>} {displayError && (
<p className="text-sm text-red-500 mt-1">{displayError}</p>
)}
</div> </div>
); );
}; };

View File

@@ -19,6 +19,10 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { StdErrNotification } from "@/lib/notificationTypes"; import { StdErrNotification } from "@/lib/notificationTypes";
import {
LoggingLevel,
LoggingLevelSchema,
} from "@modelcontextprotocol/sdk/types.js";
import useTheme from "../lib/useTheme"; import useTheme from "../lib/useTheme";
import { version } from "../../../package.json"; import { version } from "../../../package.json";
@@ -39,6 +43,9 @@ interface SidebarProps {
setBearerToken: (token: string) => void; setBearerToken: (token: string) => void;
onConnect: () => void; onConnect: () => void;
stdErrNotifications: StdErrNotification[]; stdErrNotifications: StdErrNotification[];
logLevel: LoggingLevel;
sendLogLevelRequest: (level: LoggingLevel) => void;
loggingSupported: boolean;
} }
const Sidebar = ({ const Sidebar = ({
@@ -57,6 +64,9 @@ const Sidebar = ({
setBearerToken, setBearerToken,
onConnect, onConnect,
stdErrNotifications, stdErrNotifications,
logLevel,
sendLogLevelRequest,
loggingSupported,
}: SidebarProps) => { }: SidebarProps) => {
const [theme, setTheme] = useTheme(); const [theme, setTheme] = useTheme();
const [showEnvVars, setShowEnvVars] = useState(false); const [showEnvVars, setShowEnvVars] = useState(false);
@@ -290,6 +300,28 @@ const Sidebar = ({
: "Disconnected"} : "Disconnected"}
</span> </span>
</div> </div>
{loggingSupported && connectionStatus === "connected" && (
<div className="space-y-2">
<label className="text-sm font-medium">Logging Level</label>
<Select
value={logLevel}
onValueChange={(value: LoggingLevel) =>
sendLogLevelRequest(value)
}
>
<SelectTrigger>
<SelectValue placeholder="Select logging level" />
</SelectTrigger>
<SelectContent>
{Object.values(LoggingLevelSchema.enum).map((level) => (
<SelectItem value={level}>{level}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{stdErrNotifications.length > 0 && ( {stdErrNotifications.length > 0 && (
<> <>
<div className="mt-4 border-t border-gray-200 pt-4"> <div className="mt-4 border-t border-gray-200 pt-4">

View File

@@ -6,6 +6,7 @@ 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 { Textarea } from "@/components/ui/textarea";
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm"; import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
import { generateDefaultValue } from "@/utils/schemaUtils";
import { import {
ListToolsResult, ListToolsResult,
Tool, Tool,
@@ -214,7 +215,10 @@ const ToolsTab = ({
description: prop.description, description: prop.description,
items: prop.items, items: prop.items,
}} }}
value={(params[key] as JsonValue) ?? {}} value={
(params[key] as JsonValue) ??
generateDefaultValue(prop)
}
onChange={(newValue: JsonValue) => { onChange={(newValue: JsonValue) => {
setParams({ setParams({
...params, ...params,

View File

@@ -0,0 +1,180 @@
import { updateValueAtPath, getValueAtPath } from "../jsonPathUtils";
import { JsonValue } from "../../components/DynamicJsonForm";
describe("updateValueAtPath", () => {
// Basic functionality tests
test("returns the new value when path is empty", () => {
expect(updateValueAtPath({ foo: "bar" }, [], "newValue")).toBe("newValue");
});
test("initializes an empty object when input is null/undefined and path starts with a string", () => {
expect(updateValueAtPath(null as any, ["foo"], "bar")).toEqual({
foo: "bar",
});
expect(updateValueAtPath(undefined as any, ["foo"], "bar")).toEqual({
foo: "bar",
});
});
test("initializes an empty array when input is null/undefined and path starts with a number", () => {
expect(updateValueAtPath(null as any, ["0"], "bar")).toEqual(["bar"]);
expect(updateValueAtPath(undefined as any, ["0"], "bar")).toEqual(["bar"]);
});
// Object update tests
test("updates a simple object property", () => {
const obj = { name: "John", age: 30 };
expect(updateValueAtPath(obj, ["age"], 31)).toEqual({
name: "John",
age: 31,
});
});
test("updates a nested object property", () => {
const obj = { user: { name: "John", address: { city: "New York" } } };
expect(
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
});
test("creates missing object properties", () => {
const obj = { user: { name: "John" } };
expect(
updateValueAtPath(obj, ["user", "address", "city"], "Boston"),
).toEqual({ user: { name: "John", address: { city: "Boston" } } });
});
// Array update tests
test("updates an array item", () => {
const arr = [1, 2, 3, 4];
expect(updateValueAtPath(arr, ["2"], 5)).toEqual([1, 2, 5, 4]);
});
test("extends an array when index is out of bounds", () => {
const arr = [1, 2, 3];
const result = updateValueAtPath(arr, ["5"], "new") as JsonValue[];
// Check overall array structure
expect(result).toEqual([1, 2, 3, null, null, "new"]);
// Explicitly verify that indices 3 and 4 contain null, not undefined
expect(result[3]).toBe(null);
expect(result[4]).toBe(null);
// Verify these aren't "holes" in the array (important distinction)
expect(3 in result).toBe(true);
expect(4 in result).toBe(true);
// Verify the array has the correct length
expect(result.length).toBe(6);
// Verify the array doesn't have holes by checking every index exists
expect(result.every((_, index: number) => index in result)).toBe(true);
});
test("updates a nested array item", () => {
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
expect(updateValueAtPath(obj, ["users", "1", "name"], "Janet")).toEqual({
users: [{ name: "John" }, { name: "Janet" }],
});
});
// Error handling tests
test("returns original value when trying to update a primitive with a path", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const result = updateValueAtPath("string", ["foo"], "bar");
expect(result).toBe("string");
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns original array when index is invalid", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const arr = [1, 2, 3];
expect(updateValueAtPath(arr, ["invalid"], 4)).toEqual(arr);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("returns original array when index is negative", () => {
const spy = jest.spyOn(console, "error").mockImplementation();
const arr = [1, 2, 3];
expect(updateValueAtPath(arr, ["-1"], 4)).toEqual(arr);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
test("handles sparse arrays correctly by filling holes with null", () => {
// Create a sparse array by deleting an element
const sparseArr = [1, 2, 3];
delete sparseArr[1]; // Now sparseArr is [1, <1 empty item>, 3]
// Update a value beyond the array length
const result = updateValueAtPath(sparseArr, ["5"], "new") as JsonValue[];
// Check overall array structure
expect(result).toEqual([1, null, 3, null, null, "new"]);
// Explicitly verify that index 1 (the hole) contains null, not undefined
expect(result[1]).toBe(null);
// Verify this isn't a hole in the array
expect(1 in result).toBe(true);
// Verify all indices contain null (not undefined)
expect(result[1]).not.toBe(undefined);
expect(result[3]).toBe(null);
expect(result[4]).toBe(null);
});
});
describe("getValueAtPath", () => {
test("returns the original value when path is empty", () => {
const obj = { foo: "bar" };
expect(getValueAtPath(obj, [])).toBe(obj);
});
test("returns the value at a simple path", () => {
const obj = { name: "John", age: 30 };
expect(getValueAtPath(obj, ["name"])).toBe("John");
});
test("returns the value at a nested path", () => {
const obj = { user: { name: "John", address: { city: "New York" } } };
expect(getValueAtPath(obj, ["user", "address", "city"])).toBe("New York");
});
test("returns default value when path does not exist", () => {
const obj = { user: { name: "John" } };
expect(getValueAtPath(obj, ["user", "address", "city"], "Unknown")).toBe(
"Unknown",
);
});
test("returns default value when input is null/undefined", () => {
expect(getValueAtPath(null as any, ["foo"], "default")).toBe("default");
expect(getValueAtPath(undefined as any, ["foo"], "default")).toBe(
"default",
);
});
test("handles array indices correctly", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["1"])).toBe("b");
});
test("returns default value for out of bounds array indices", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["5"], "default")).toBe("default");
});
test("returns default value for invalid array indices", () => {
const arr = ["a", "b", "c"];
expect(getValueAtPath(arr, ["invalid"], "default")).toBe("default");
});
test("navigates through mixed object and array paths", () => {
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
expect(getValueAtPath(obj, ["users", "1", "name"])).toBe("Jane");
});
});

View File

@@ -0,0 +1,139 @@
import { generateDefaultValue, formatFieldLabel } from "../schemaUtils";
import { JsonSchemaType } from "../../components/DynamicJsonForm";
describe("generateDefaultValue", () => {
test("generates default string", () => {
expect(generateDefaultValue({ type: "string", required: true })).toBe("");
});
test("generates default number", () => {
expect(generateDefaultValue({ type: "number", required: true })).toBe(0);
});
test("generates default integer", () => {
expect(generateDefaultValue({ type: "integer", required: true })).toBe(0);
});
test("generates default boolean", () => {
expect(generateDefaultValue({ type: "boolean", required: true })).toBe(
false,
);
});
test("generates default array", () => {
expect(generateDefaultValue({ type: "array", required: true })).toEqual([]);
});
test("generates default empty object", () => {
expect(generateDefaultValue({ type: "object", required: true })).toEqual(
{},
);
});
test("generates default null for unknown types", () => {
// @ts-expect-error Testing with invalid type
expect(generateDefaultValue({ type: "unknown", required: true })).toBe(
null,
);
});
test("generates empty array for non-required array", () => {
expect(generateDefaultValue({ type: "array", required: false })).toEqual(
[],
);
});
test("generates empty object for non-required object", () => {
expect(generateDefaultValue({ type: "object", required: false })).toEqual(
{},
);
});
test("generates null for non-required primitive types", () => {
expect(generateDefaultValue({ type: "string", required: false })).toBe(
null,
);
expect(generateDefaultValue({ type: "number", required: false })).toBe(
null,
);
expect(generateDefaultValue({ type: "boolean", required: false })).toBe(
null,
);
});
test("generates object with properties", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
properties: {
name: { type: "string", required: true },
age: { type: "number", required: true },
isActive: { type: "boolean", required: true },
},
};
expect(generateDefaultValue(schema)).toEqual({
name: "",
age: 0,
isActive: false,
});
});
test("handles nested objects", () => {
const schema: JsonSchemaType = {
type: "object",
required: true,
properties: {
user: {
type: "object",
required: true,
properties: {
name: { type: "string", required: true },
address: {
type: "object",
required: true,
properties: {
city: { type: "string", required: true },
},
},
},
},
},
};
expect(generateDefaultValue(schema)).toEqual({
user: {
name: "",
address: {
city: "",
},
},
});
});
test("uses schema default value when provided", () => {
expect(generateDefaultValue({ type: "string", default: "test" })).toBe(
"test",
);
});
});
describe("formatFieldLabel", () => {
test("formats camelCase", () => {
expect(formatFieldLabel("firstName")).toBe("First Name");
});
test("formats snake_case", () => {
expect(formatFieldLabel("first_name")).toBe("First name");
});
test("formats single word", () => {
expect(formatFieldLabel("name")).toBe("Name");
});
test("formats mixed case with underscores", () => {
expect(formatFieldLabel("user_firstName")).toBe("User first Name");
});
test("handles empty string", () => {
expect(formatFieldLabel("")).toBe("");
});
});

View File

@@ -0,0 +1,149 @@
import { JsonValue } from "../components/DynamicJsonForm";
export type JsonObject = { [key: string]: JsonValue };
/**
* Updates a value at a specific path in a nested JSON structure
* @param obj The original JSON value
* @param path Array of keys/indices representing the path to the value
* @param value The new value to set
* @returns A new JSON value with the updated path
*/
export function updateValueAtPath(
obj: JsonValue,
path: string[],
value: JsonValue,
): JsonValue {
if (path.length === 0) return value;
if (obj === null || obj === undefined) {
obj = !isNaN(Number(path[0])) ? [] : {};
}
if (Array.isArray(obj)) {
return updateArray(obj, path, value);
} else if (typeof obj === "object" && obj !== null) {
return updateObject(obj as JsonObject, path, value);
} else {
console.error(
`Cannot update path ${path.join(".")} in non-object/array value:`,
obj,
);
return obj;
}
}
/**
* Updates an array at a specific path
*/
function updateArray(
array: JsonValue[],
path: string[],
value: JsonValue,
): JsonValue[] {
const [index, ...restPath] = path;
const arrayIndex = Number(index);
if (isNaN(arrayIndex)) {
console.error(`Invalid array index: ${index}`);
return array;
}
if (arrayIndex < 0) {
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
return array;
}
let newArray: JsonValue[] = [];
for (let i = 0; i < array.length; i++) {
newArray[i] = i in array ? array[i] : null;
}
if (arrayIndex >= newArray.length) {
const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null);
// Copy over the existing elements (now guaranteed to be dense)
for (let i = 0; i < newArray.length; i++) {
extendedArray[i] = newArray[i];
}
newArray = extendedArray;
}
if (restPath.length === 0) {
newArray[arrayIndex] = value;
} else {
newArray[arrayIndex] = updateValueAtPath(
newArray[arrayIndex],
restPath,
value,
);
}
return newArray;
}
/**
* Updates an object at a specific path
*/
function updateObject(
obj: JsonObject,
path: string[],
value: JsonValue,
): JsonObject {
const [key, ...restPath] = path;
// Validate object key
if (typeof key !== "string") {
console.error(`Invalid object key: ${key}`);
return obj;
}
const newObj = { ...obj };
if (restPath.length === 0) {
newObj[key] = value;
} else {
// Ensure key exists
if (!(key in newObj)) {
newObj[key] = {};
}
newObj[key] = updateValueAtPath(newObj[key], restPath, value);
}
return newObj;
}
/**
* Gets a value at a specific path in a nested JSON structure
* @param obj The JSON value to traverse
* @param path Array of keys/indices representing the path to the value
* @param defaultValue Value to return if path doesn't exist
* @returns The value at the path, or defaultValue if not found
*/
export function getValueAtPath(
obj: JsonValue,
path: string[],
defaultValue: JsonValue = null,
): JsonValue {
if (path.length === 0) return obj;
const [first, ...rest] = path;
if (obj === null || obj === undefined) {
return defaultValue;
}
if (Array.isArray(obj)) {
const index = Number(first);
if (isNaN(index) || index < 0 || index >= obj.length) {
return defaultValue;
}
return getValueAtPath(obj[index], rest, defaultValue);
}
if (typeof obj === "object" && obj !== null) {
if (!(first in obj)) {
return defaultValue;
}
return getValueAtPath((obj as JsonObject)[first], rest, defaultValue);
}
return defaultValue;
}

View File

@@ -0,0 +1,57 @@
import { JsonValue, JsonSchemaType } from "../components/DynamicJsonForm";
import { JsonObject } from "./jsonPathUtils";
/**
* Generates a default value based on a JSON schema type
* @param schema The JSON schema definition
* @returns A default value matching the schema type, or null for non-required fields
*/
export function generateDefaultValue(schema: JsonSchemaType): JsonValue {
if ("default" in schema) {
return schema.default;
}
if (!schema.required) {
if (schema.type === "array") return [];
if (schema.type === "object") return {};
return null;
}
switch (schema.type) {
case "string":
return "";
case "number":
case "integer":
return 0;
case "boolean":
return false;
case "array":
return [];
case "object": {
if (!schema.properties) return {};
const obj: JsonObject = {};
Object.entries(schema.properties)
.filter(([, prop]) => prop.required)
.forEach(([key, prop]) => {
const value = generateDefaultValue(prop);
obj[key] = value;
});
return obj;
}
default:
return null;
}
}
/**
* Formats a field key into a human-readable label
* @param key The field key to format
* @returns A formatted label string
*/
export function formatFieldLabel(key: string): string {
return key
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
.replace(/_/g, " ") // Replace underscores with spaces
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
}

10
client/tsconfig.jest.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"jsx": "react-jsx",
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "node"
},
"include": ["src"]
}

3269
package-lock.json generated

File diff suppressed because it is too large Load Diff