diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e62fd18 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ +# MCP Inspector Development Guide + +## Build Commands + +- Build all: `npm run build` +- Build client: `npm run build-client` +- Build server: `npm run build-server` +- Development mode: `npm run dev` (use `npm run dev:windows` on Windows) +- Format code: `npm run prettier-fix` +- Client lint: `cd client && npm run lint` + +## Code Style Guidelines + +- Use TypeScript with proper type annotations +- Follow React functional component patterns with hooks +- Use ES modules (import/export) not CommonJS +- Use Prettier for formatting (auto-formatted on commit) +- Follow existing naming conventions: + - camelCase for variables and functions + - PascalCase for component names and types + - kebab-case for file names +- Use async/await for asynchronous operations +- Implement proper error handling with try/catch blocks +- Use Tailwind CSS for styling in the client +- Keep components small and focused on a single responsibility + +## Project Organization + +The project is organized as a monorepo with workspaces: + +- `client/`: React frontend with Vite, TypeScript and Tailwind +- `server/`: Express backend with TypeScript +- `bin/`: CLI scripts diff --git a/client/package.json b/client/package.json index 4e95b83..ce66367 100644 --- a/client/package.json +++ b/client/package.json @@ -22,15 +22,18 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.4.1", + "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.3", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tabs": "^1.1.1", "@types/prismjs": "^1.26.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.4", "lucide-react": "^0.447.0", "prismjs": "^1.29.0", "pkce-challenge": "^4.1.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index 246e035..a9adea5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -151,6 +151,8 @@ const App = () => { requestHistory, makeRequest: makeConnectionRequest, sendNotification, + handleCompletion, + completionsSupported, connect: connectMcpServer, } = useConnection({ transportType, @@ -177,29 +179,6 @@ const App = () => { getRoots: () => rootsRef.current, }); - const makeRequest = async ( - request: ClientRequest, - schema: T, - tabKey?: keyof typeof errors, - ) => { - try { - const response = await makeConnectionRequest(request, schema); - if (tabKey !== undefined) { - clearError(tabKey); - } - return response; - } catch (e) { - const errorString = (e as Error).message ?? String(e); - if (tabKey !== undefined) { - setErrors((prev) => ({ - ...prev, - [tabKey]: errorString, - })); - } - throw e; - } - }; - useEffect(() => { localStorage.setItem("lastCommand", command); }, [command]); @@ -264,6 +243,29 @@ const App = () => { setErrors((prev) => ({ ...prev, [tabKey]: null })); }; + const makeRequest = async ( + request: ClientRequest, + schema: T, + tabKey?: keyof typeof errors, + ) => { + try { + const response = await makeConnectionRequest(request, schema); + if (tabKey !== undefined) { + clearError(tabKey); + } + return response; + } catch (e) { + const errorString = (e as Error).message ?? String(e); + if (tabKey !== undefined) { + setErrors((prev) => ({ + ...prev, + [tabKey]: errorString, + })); + } + throw e; + } + }; + const listResources = async () => { const response = await makeRequest( { @@ -483,6 +485,8 @@ const App = () => { clearError("resources"); setSelectedResource(resource); }} + handleCompletion={handleCompletion} + completionsSupported={completionsSupported} resourceContent={resourceContent} nextCursor={nextResourceCursor} nextTemplateCursor={nextResourceTemplateCursor} @@ -507,6 +511,8 @@ const App = () => { clearError("prompts"); setSelectedPrompt(prompt); }} + handleCompletion={handleCompletion} + completionsSupported={completionsSupported} promptContent={promptContent} nextCursor={nextPromptCursor} error={errors.prompts} diff --git a/client/src/components/DynamicJsonForm.tsx b/client/src/components/DynamicJsonForm.tsx index ff26118..a15b57e 100644 --- a/client/src/components/DynamicJsonForm.tsx +++ b/client/src/components/DynamicJsonForm.tsx @@ -215,23 +215,112 @@ const DynamicJsonForm = ({ return; } - const newValue = { - ...(typeof value === "object" && value !== null && !Array.isArray(value) - ? value - : {}), - } as JsonObject; - let current: JsonObject = newValue; + const updateArray = ( + array: JsonValue[], + path: string[], + value: JsonValue, + ): JsonValue[] => { + const [index, ...restPath] = path; + const arrayIndex = Number(index); - for (let i = 0; i < path.length - 1; i++) { - const key = path[i]; - if (!(key in current)) { - current[key] = {}; + // Validate array index + if (isNaN(arrayIndex)) { + console.error(`Invalid array index: ${index}`); + return array; } - current = current[key] as JsonObject; - } - current[path[path.length - 1]] = fieldValue; - onChange(newValue); + // 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 { + if (!current) { + 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); + } catch (error) { + console.error("Failed to update form value:", error); + // Keep the original value unchanged + onChange(value); + } }; return ( diff --git a/client/src/components/PromptsTab.tsx b/client/src/components/PromptsTab.tsx index df8b8a5..f88b16f 100644 --- a/client/src/components/PromptsTab.tsx +++ b/client/src/components/PromptsTab.tsx @@ -1,13 +1,18 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { Combobox } from "@/components/ui/combobox"; import { Label } from "@/components/ui/label"; import { TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import { ListPromptsResult } from "@modelcontextprotocol/sdk/types.js"; +import { + ListPromptsResult, + PromptReference, + ResourceReference, +} from "@modelcontextprotocol/sdk/types.js"; import { AlertCircle } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import ListPane from "./ListPane"; +import { useCompletionState } from "@/lib/hooks/useCompletionState"; export type Prompt = { name: string; @@ -26,6 +31,8 @@ const PromptsTab = ({ getPrompt, selectedPrompt, setSelectedPrompt, + handleCompletion, + completionsSupported, promptContent, nextCursor, error, @@ -36,14 +43,37 @@ const PromptsTab = ({ getPrompt: (name: string, args: Record) => void; selectedPrompt: Prompt | null; setSelectedPrompt: (prompt: Prompt) => void; + handleCompletion: ( + ref: PromptReference | ResourceReference, + argName: string, + value: string, + ) => Promise; + completionsSupported: boolean; promptContent: string; nextCursor: ListPromptsResult["nextCursor"]; error: string | null; }) => { const [promptArgs, setPromptArgs] = useState>({}); + const { completions, clearCompletions, requestCompletions } = + useCompletionState(handleCompletion, completionsSupported); - const handleInputChange = (argName: string, value: string) => { + useEffect(() => { + clearCompletions(); + }, [clearCompletions, selectedPrompt]); + + const handleInputChange = async (argName: string, value: string) => { setPromptArgs((prev) => ({ ...prev, [argName]: value })); + + if (selectedPrompt) { + requestCompletions( + { + type: "ref/prompt", + name: selectedPrompt.name, + }, + argName, + value, + ); + } }; const handleGetPrompt = () => { @@ -96,14 +126,17 @@ const PromptsTab = ({ {selectedPrompt.arguments?.map((arg) => (
- - handleInputChange(arg.name, e.target.value) + onChange={(value) => handleInputChange(arg.name, value)} + onInputChange={(value) => + handleInputChange(arg.name, value) } + options={completions[arg.name] || []} /> + {arg.description && (

{arg.description} diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index e948881..93127a9 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -1,16 +1,20 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Combobox } from "@/components/ui/combobox"; import { TabsContent } from "@/components/ui/tabs"; import { ListResourcesResult, Resource, ResourceTemplate, ListResourceTemplatesResult, + ResourceReference, + PromptReference, } from "@modelcontextprotocol/sdk/types.js"; import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react"; import ListPane from "./ListPane"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { useCompletionState } from "@/lib/hooks/useCompletionState"; const ResourcesTab = ({ resources, @@ -22,6 +26,8 @@ const ResourcesTab = ({ readResource, selectedResource, setSelectedResource, + handleCompletion, + completionsSupported, resourceContent, nextCursor, nextTemplateCursor, @@ -36,6 +42,12 @@ const ResourcesTab = ({ readResource: (uri: string) => void; selectedResource: Resource | null; setSelectedResource: (resource: Resource | null) => void; + handleCompletion: ( + ref: ResourceReference | PromptReference, + argName: string, + value: string, + ) => Promise; + completionsSupported: boolean; resourceContent: string; nextCursor: ListResourcesResult["nextCursor"]; nextTemplateCursor: ListResourceTemplatesResult["nextCursor"]; @@ -47,6 +59,13 @@ const ResourcesTab = ({ {}, ); + const { completions, clearCompletions, requestCompletions } = + useCompletionState(handleCompletion, completionsSupported); + + useEffect(() => { + clearCompletions(); + }, [clearCompletions]); + const fillTemplate = ( template: string, values: Record, @@ -57,6 +76,21 @@ const ResourcesTab = ({ ); }; + const handleTemplateValueChange = async (key: string, value: string) => { + setTemplateValues((prev) => ({ ...prev, [key]: value })); + + if (selectedTemplate?.uriTemplate) { + requestCompletions( + { + type: "ref/resource", + uri: selectedTemplate.uriTemplate, + }, + key, + value, + ); + } + }; + const handleReadTemplateResource = () => { if (selectedTemplate) { const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues); @@ -162,22 +196,18 @@ const ResourcesTab = ({ const key = param.slice(1, -1); return (

- - {key} + - setTemplateValues({ - ...templateValues, - [key]: e.target.value, - }) + onChange={(value) => + handleTemplateValueChange(key, value) } - className="mt-1" + onInputChange={(value) => + handleTemplateValueChange(key, value) + } + options={completions[key] || []} />
); diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 418e58e..777db80 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -17,12 +17,6 @@ import ListPane from "./ListPane"; import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js"; -type SchemaProperty = { - type: string; - description?: string; - properties?: Record; -}; - const ToolsTab = ({ tools, listTools, @@ -168,7 +162,7 @@ const ToolsTab = ({

{Object.entries(selectedTool.inputSchema.properties ?? []).map( ([key, value]) => { - const prop = value as SchemaProperty; + const prop = value as JsonSchemaType; return (