diff --git a/client/src/App.tsx b/client/src/App.tsx index 055552a30..8160fa4ed 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,7 +1,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { - CallToolResultSchema, + CompatibilityCallToolResultSchema, ClientRequest, CreateMessageRequestSchema, CreateMessageResult, @@ -9,12 +9,18 @@ import { GetPromptResultSchema, ListPromptsResultSchema, ListResourcesResultSchema, + ListResourceTemplatesResultSchema, + ListRootsRequestSchema, ListToolsResultSchema, ProgressNotificationSchema, ReadResourceResultSchema, Resource, + ResourceTemplate, + Root, ServerNotification, Tool, + CompatibilityCallToolResult, + ClientNotification, } from "@modelcontextprotocol/sdk/types.js"; import { useEffect, useRef, useState } from "react"; @@ -37,9 +43,12 @@ import { Play, Send, Terminal, + FolderTree, + ChevronDown, + ChevronRight, } from "lucide-react"; -import { AnyZodObject } from "zod"; +import { ZodType } from "zod"; import "./App.css"; import ConsoleTab from "./components/ConsoleTab"; import HistoryAndNotifications from "./components/History"; @@ -47,6 +56,7 @@ import PingTab from "./components/PingTab"; import PromptsTab, { Prompt } from "./components/PromptsTab"; import RequestsTab from "./components/RequestsTabs"; import ResourcesTab from "./components/ResourcesTab"; +import RootsTab from "./components/RootsTab"; import SamplingTab, { PendingRequest } from "./components/SamplingTab"; import Sidebar from "./components/Sidebar"; import ToolsTab from "./components/ToolsTab"; @@ -56,11 +66,15 @@ const App = () => { "disconnected" | "connected" | "error" >("disconnected"); const [resources, setResources] = useState([]); + const [resourceTemplates, setResourceTemplates] = useState< + ResourceTemplate[] + >([]); const [resourceContent, setResourceContent] = useState(""); const [prompts, setPrompts] = useState([]); const [promptContent, setPromptContent] = useState(""); const [tools, setTools] = useState([]); - const [toolResult, setToolResult] = useState(""); + const [toolResult, setToolResult] = + useState(null); const [error, setError] = useState(null); const [command, setCommand] = useState(() => { return ( @@ -77,10 +91,13 @@ const App = () => { const [url, setUrl] = useState("http://localhost:3001/sse"); const [transportType, setTransportType] = useState<"stdio" | "sse">("stdio"); const [requestHistory, setRequestHistory] = useState< - { request: string; response: string }[] + { request: string; response?: string }[] >([]); const [mcpClient, setMcpClient] = useState(null); const [notifications, setNotifications] = useState([]); + const [roots, setRoots] = useState([]); + const [env, setEnv] = useState>({}); + const [showEnvVars, setShowEnvVars] = useState(false); const [pendingSampleRequests, setPendingSampleRequests] = useState< Array< @@ -116,6 +133,9 @@ const App = () => { const [nextResourceCursor, setNextResourceCursor] = useState< string | undefined >(); + const [nextResourceTemplateCursor, setNextResourceTemplateCursor] = useState< + string | undefined + >(); const [nextPromptCursor, setNextPromptCursor] = useState< string | undefined >(); @@ -130,14 +150,26 @@ const App = () => { localStorage.setItem("lastArgs", args); }, [args]); - const pushHistory = (request: object, response: object) => { + useEffect(() => { + fetch("http://localhost:3000/default-environment") + .then((response) => response.json()) + .then((data) => setEnv(data)) + .catch((error) => + console.error("Error fetching default environment:", error), + ); + }, []); + + const pushHistory = (request: object, response?: object) => { setRequestHistory((prev) => [ ...prev, - { request: JSON.stringify(request), response: JSON.stringify(response) }, + { + request: JSON.stringify(request), + response: response !== undefined ? JSON.stringify(response) : undefined, + }, ]); }; - const makeRequest = async ( + const makeRequest = async >( request: ClientRequest, schema: T, ) => { @@ -155,6 +187,20 @@ const App = () => { } }; + const sendNotification = async (notification: ClientNotification) => { + if (!mcpClient) { + throw new Error("MCP client not connected"); + } + + try { + await mcpClient.notification(notification); + pushHistory(notification); + } catch (e: unknown) { + setError((e as Error).message); + throw e; + } + }; + const listResources = async () => { const response = await makeRequest( { @@ -167,6 +213,22 @@ const App = () => { setNextResourceCursor(response.nextCursor); }; + const listResourceTemplates = async () => { + const response = await makeRequest( + { + method: "resources/templates/list" as const, + params: nextResourceTemplateCursor + ? { cursor: nextResourceTemplateCursor } + : {}, + }, + ListResourceTemplatesResultSchema, + ); + setResourceTemplates( + resourceTemplates.concat(response.resourceTemplates ?? []), + ); + setNextResourceTemplateCursor(response.nextCursor); + }; + const readResource = async (uri: string) => { const response = await makeRequest( { @@ -225,9 +287,13 @@ const App = () => { }, }, }, - CallToolResultSchema, + CompatibilityCallToolResultSchema, ); - setToolResult(JSON.stringify(response.toolResult, null, 2)); + setToolResult(response); + }; + + const handleRootsChange = async () => { + sendNotification({ method: "notifications/roots/list_changed" }); }; const connectMcpServer = async () => { @@ -243,6 +309,7 @@ const App = () => { if (transportType === "stdio") { backendUrl.searchParams.append("command", command); backendUrl.searchParams.append("args", args); + backendUrl.searchParams.append("env", JSON.stringify(env)); } else { backendUrl.searchParams.append("url", url); } @@ -269,6 +336,10 @@ const App = () => { }); }); + client.setRequestHandler(ListRootsRequestSchema, async () => { + return { roots }; + }); + setMcpClient(client); setConnectionStatus("connected"); } catch (e) { @@ -326,6 +397,66 @@ const App = () => { Connect + {transportType === "stdio" && ( +
+ + {showEnvVars && ( +
+ {Object.entries(env).map(([key, value]) => ( +
+ + setEnv((prev) => ({ + ...prev, + [e.target.value]: value, + })) + } + /> + + setEnv((prev) => ({ + ...prev, + [key]: e.target.value, + })) + } + /> + +
+ ))} + +
+ )} +
+ )} {mcpClient ? ( @@ -363,17 +494,24 @@ const App = () => { )} + + + Roots +
{ selectedTool={selectedTool} setSelectedTool={(tool) => { setSelectedTool(tool); - setToolResult(""); + setToolResult(null); }} toolResult={toolResult} nextCursor={nextToolCursor} @@ -416,6 +554,11 @@ const App = () => { onApprove={handleApproveSampling} onReject={handleRejectSampling} /> +
) : ( diff --git a/client/src/components/History.tsx b/client/src/components/History.tsx index 732f0ab91..832194b8f 100644 --- a/client/src/components/History.tsx +++ b/client/src/components/History.tsx @@ -6,7 +6,7 @@ const HistoryAndNotifications = ({ requestHistory, serverNotifications, }: { - requestHistory: Array<{ request: string; response: string | null }>; + requestHistory: Array<{ request: string; response?: string }>; serverNotifications: ServerNotification[]; }) => { const [expandedRequests, setExpandedRequests] = useState<{ diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index af6a19ee8..d5c5a67b1 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -1,91 +1,199 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { TabsContent } from "@/components/ui/tabs"; import { ListResourcesResult, Resource, + ResourceTemplate, + ListResourceTemplatesResult, } from "@modelcontextprotocol/sdk/types.js"; import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react"; import ListPane from "./ListPane"; +import { useState } from "react"; const ResourcesTab = ({ resources, + resourceTemplates, listResources, + listResourceTemplates, readResource, selectedResource, setSelectedResource, resourceContent, nextCursor, + nextTemplateCursor, error, }: { resources: Resource[]; + resourceTemplates: ResourceTemplate[]; listResources: () => void; + listResourceTemplates: () => void; readResource: (uri: string) => void; selectedResource: Resource | null; - setSelectedResource: (resource: Resource) => void; + setSelectedResource: (resource: Resource | null) => void; resourceContent: string; nextCursor: ListResourcesResult["nextCursor"]; + nextTemplateCursor: ListResourceTemplatesResult["nextCursor"]; error: string | null; -}) => ( - - { - setSelectedResource(resource); - readResource(resource.uri); - }} - renderItem={(resource) => ( -
- - - {resource.name} - - -
- )} - title="Resources" - buttonText={nextCursor ? "List More Resources" : "List Resources"} - isButtonDisabled={!nextCursor && resources.length > 0} - /> +}) => { + const [selectedTemplate, setSelectedTemplate] = + useState(null); + const [templateValues, setTemplateValues] = useState>( + {}, + ); -
-
-

- {selectedResource ? selectedResource.name : "Select a resource"} -

- {selectedResource && ( - + const fillTemplate = ( + template: string, + values: Record, + ): string => { + return template.replace( + /{([^}]+)}/g, + (_, key) => values[key] || `{${key}}`, + ); + }; + + const handleReadTemplateResource = () => { + if (selectedTemplate) { + const uri = fillTemplate(selectedTemplate.uriTemplate, templateValues); + readResource(uri); + setSelectedTemplate(null); + // We don't have the full Resource object here, so we create a partial one + setSelectedResource({ uri, name: uri } as Resource); + } + }; + + return ( + + { + setSelectedResource(resource); + readResource(resource.uri); + setSelectedTemplate(null); + }} + renderItem={(resource) => ( +
+ + + {resource.name} + + +
)} -
-
- {error ? ( - - - Error - {error} - - ) : selectedResource ? ( -
-            {resourceContent}
-          
- ) : ( - - - Select a resource from the list to view its contents - - + title="Resources" + buttonText={nextCursor ? "List More Resources" : "List Resources"} + isButtonDisabled={!nextCursor && resources.length > 0} + /> + + { + setSelectedTemplate(template); + setSelectedResource(null); + setTemplateValues({}); + }} + renderItem={(template) => ( +
+ + + {template.name} + + +
)} + title="Resource Templates" + buttonText={ + nextTemplateCursor ? "List More Templates" : "List Templates" + } + isButtonDisabled={!nextTemplateCursor && resourceTemplates.length > 0} + /> + +
+
+

+ {selectedResource + ? selectedResource.name + : selectedTemplate + ? selectedTemplate.name + : "Select a resource or template"} +

+ {selectedResource && ( + + )} +
+
+ {error ? ( + + + Error + {error} + + ) : selectedResource ? ( +
+              {resourceContent}
+            
+ ) : selectedTemplate ? ( +
+

+ {selectedTemplate.description} +

+ {selectedTemplate.uriTemplate + .match(/{([^}]+)}/g) + ?.map((param) => { + const key = param.slice(1, -1); + return ( +
+ + + setTemplateValues({ + ...templateValues, + [key]: e.target.value, + }) + } + className="mt-1" + /> +
+ ); + })} + +
+ ) : ( + + + Select a resource or template from the list to view its contents + + + )} +
-
- -); + + ); +}; export default ResourcesTab; diff --git a/client/src/components/RootsTab.tsx b/client/src/components/RootsTab.tsx new file mode 100644 index 000000000..33f60d5d9 --- /dev/null +++ b/client/src/components/RootsTab.tsx @@ -0,0 +1,77 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { TabsContent } from "@/components/ui/tabs"; +import { Root } from "@modelcontextprotocol/sdk/types.js"; +import { Plus, Minus, Save } from "lucide-react"; + +const RootsTab = ({ + roots, + setRoots, + onRootsChange, +}: { + roots: Root[]; + setRoots: React.Dispatch>; + onRootsChange: () => void; +}) => { + const addRoot = () => { + setRoots((currentRoots) => [...currentRoots, { uri: "file://", name: "" }]); + }; + + const removeRoot = (index: number) => { + setRoots((currentRoots) => currentRoots.filter((_, i) => i !== index)); + }; + + const updateRoot = (index: number, field: keyof Root, value: string) => { + setRoots((currentRoots) => + currentRoots.map((root, i) => + i === index ? { ...root, [field]: value } : root, + ), + ); + }; + + const handleSave = () => { + onRootsChange(); + }; + + return ( + + + + Configure the root directories that the server can access + + + + {roots.map((root, index) => ( +
+ updateRoot(index, "uri", e.target.value)} + className="flex-1" + /> + +
+ ))} + +
+ + +
+
+ ); +}; + +export default RootsTab; diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index dc6a18f63..4c6e09788 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -3,11 +3,13 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { TabsContent } from "@/components/ui/tabs"; -import { ListToolsResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { CallToolResult, ListToolsResult, Tool } from "@modelcontextprotocol/sdk/types.js"; import { AlertCircle, Send } from "lucide-react"; import { useState } from "react"; import ListPane from "./ListPane"; +import { CompatibilityCallToolResult } from "@modelcontextprotocol/sdk/types.js"; + const ToolsTab = ({ tools, listTools, @@ -23,12 +25,58 @@ const ToolsTab = ({ callTool: (name: string, params: Record) => void; selectedTool: Tool | null; setSelectedTool: (tool: Tool) => void; - toolResult: string; + toolResult: CompatibilityCallToolResult | null; nextCursor: ListToolsResult["nextCursor"]; error: string | null; }) => { const [params, setParams] = useState>({}); + const renderToolResult = () => { + if (!toolResult) return null; + + if ("content" in toolResult) { + const structuredResult = toolResult as CallToolResult; + + return ( + <> +

+ Tool Result: {structuredResult.isError ? "Error" : "Success"} +

+ {structuredResult.content.map((item, index) => ( +
+ {item.type === "text" && ( +
+                  {item.text}
+                
+ )} + {item.type === "image" && ( + Tool result image + )} + {item.type === "resource" && ( +
+                  {JSON.stringify(item.resource, null, 2)}
+                
+ )} +
+ ))} + + ); + } else if ("toolResult" in toolResult) { + return ( + <> +

Tool Result (Legacy):

+
+            {JSON.stringify(toolResult.toolResult, null, 2)}
+          
+ + ); + } + }; + return ( Run Tool - {toolResult && ( - <> -

Tool Result:

-
-                    {toolResult}
-                  
- - )} + {toolResult && renderToolResult()}
) : ( diff --git a/package-lock.json b/package-lock.json index cd16d57a9..186417070 100644 --- a/package-lock.json +++ b/package-lock.json @@ -841,9 +841,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "0.1.0", - "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-internal/@modelcontextprotocol/sdk/-/@modelcontextprotocol/sdk-0.1.0.tgz", - "integrity": "sha512-46/FTHNZWUWbdFspKsCIixhCKwi9Hub5+HWNXC1DRIL2TSV8cdx5sTsg+Jy6I4Uc8/rd7tLZcVQ6IasIY1g4zg==", + "version": "0.3.2", + "resolved": "https://artifactory.infra.ant.dev:443/artifactory/api/npm/npm-internal/@modelcontextprotocol/sdk/-/@modelcontextprotocol/sdk-0.3.2.tgz", + "integrity": "sha512-7f4VYf43cH0VzewG1kdgoKZN56PA69VA2iix+/nlc0AOEJ6ClhebOpmeugK2ZvPctbzFuESaYW8Oh29u8Y09Jw==", "dependencies": { "content-type": "^1.0.5", "raw-body": "^3.0.0", @@ -4635,12 +4635,6 @@ } } }, - "node_modules/react-remove-scroll/node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "license": "0BSD" - }, "node_modules/react-style-singleton": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz", @@ -4811,13 +4805,6 @@ "tslib": "^2.1.0" } }, - "node_modules/rxjs/node_modules/tslib": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", - "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", - "dev": true, - "license": "0BSD" - }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", diff --git a/server/src/index.ts b/server/src/index.ts index f14f9ebf2..58e922bbf 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,7 +2,10 @@ import cors from "cors"; import EventSource from "eventsource"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { + StdioClientTransport, + getDefaultEnvironment, +} from "@modelcontextprotocol/sdk/client/stdio.js"; import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import express from "express"; import mcpProxy from "./mcpProxy.js"; @@ -24,8 +27,11 @@ const createTransport = async (query: express.Request["query"]) => { if (transportType === "stdio") { const command = query.command as string; const args = (query.args as string).split(/\s+/); - console.log(`Stdio transport: command=${command}, args=${args}`); - const transport = new StdioClientTransport({ command, args }); + const env = query.env ? JSON.parse(query.env as string) : undefined; + console.log( + `Stdio transport: command=${command}, args=${args}, env=${JSON.stringify(env)}`, + ); + const transport = new StdioClientTransport({ command, args, env }); await transport.start(); console.log("Spawned stdio transport"); return transport; @@ -79,6 +85,10 @@ app.post("/message", async (req, res) => { await transport.handlePostMessage(req, res); }); +app.get("/default-environment", (req, res) => { + res.json(getDefaultEnvironment()); +}); + const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`);