From 76fbc07343adcf89b2c96016c9dd73b06a64b843 Mon Sep 17 00:00:00 2001 From: Fredrik Jensen Date: Sat, 6 Dec 2025 15:18:25 +0100 Subject: [PATCH] imrpove inspector --- README.md | 26 +++-- .../demo-server/components/stock-entry.tsx | 1 - packages/inspector/src/App.tsx | 6 ++ .../inspector/src/components/ResultsPane.css | 2 +- packages/inspector/src/components/Sidebar.css | 9 ++ packages/inspector/src/components/Sidebar.tsx | 22 ++++- .../inspector/src/components/ToolsPanel.css | 33 ++++++- .../inspector/src/components/ToolsPanel.tsx | 28 ++++-- packages/inspector/src/hooks/useMCP.ts | 96 +++++++++++++++---- packages/library/server/bundle.ts | 31 ++++-- 10 files changed, 209 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index fc3daaf..6d07488 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ import { createUI } from 'mcp-ui-kit/server'; const server = new McpServer({ name: 'my-server', version: '1.0.0' }); -const dashboardUI = createUI('my-dashboard', import.meta.resolve('./MyComponent.tsx')); +const dashboardUI = createUI('my-dashboard', import.meta.resolve('./Dashboard.tsx')); server.registerTool( 'dashboard', @@ -68,7 +68,11 @@ server.registerTool( ## Client Usage +The file passed to `createUI()` must render a component to the `#root` element: + ```tsx +// StockDashboard.tsx +import { createRoot } from 'react-dom/client'; import { sendPrompt, callTool, useProps } from 'mcp-ui-kit/ui'; function StockDashboard() { @@ -80,27 +84,35 @@ function StockDashboard() { {symbols.map(symbol => (
{symbol} - - + +
))} ); } + +// Render to DOM +createRoot(document.getElementById('root')!).render(); ``` ## API ### Server (`mcp-ui-kit/server`) -**`createUI(name, entryUrl)`** — Creates a UI component +**`createUI(name, componentPath)`** — Creates a UI component +- `componentPath`: Path to a `.tsx` file that renders to `#root` - Returns `{ meta, component(opts?) }` -- `opts.props`: Data passed to your React component +- `opts.props`: Data passed to your React component via `useProps()` - `opts.frameSize`: `[width, height]` e.g. `['700px', '500px']` ```typescript -createUI('dashboard', import.meta.resolve('./MyComponent.tsx')); // ESM -createUI('dashboard', require.resolve('./MyComponent.tsx')); // CommonJS +createUI('dashboard', import.meta.resolve('./Dashboard.tsx')); // ESM +createUI('dashboard', require.resolve('./Dashboard.tsx')); // CommonJS ``` ### UI (`mcp-ui-kit/ui`) diff --git a/packages/demo-server/components/stock-entry.tsx b/packages/demo-server/components/stock-entry.tsx index 248dca9..6eb3e49 100644 --- a/packages/demo-server/components/stock-entry.tsx +++ b/packages/demo-server/components/stock-entry.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { createRoot } from 'react-dom/client'; import { StockDashboard } from './StockDashboard'; diff --git a/packages/inspector/src/App.tsx b/packages/inspector/src/App.tsx index f526f6e..7f6ac50 100644 --- a/packages/inspector/src/App.tsx +++ b/packages/inspector/src/App.tsx @@ -34,9 +34,12 @@ function App() { isConnected, isConnecting, sessionId, + isStateless, tools, + isRefreshing, connect, disconnect, + refreshTools, callTool, error } = useMCP() @@ -113,6 +116,7 @@ function App() { isConnected={isConnected} isConnecting={isConnecting} sessionId={sessionId} + isStateless={isStateless} onConnect={handleConnect} error={error} /> @@ -124,8 +128,10 @@ function App() { selectedTool={selectedTool} onSelectTool={setSelectedTool} onExecuteTool={handleExecuteTool} + onRefreshTools={refreshTools} isConnected={isConnected} isExecuting={isExecuting} + isRefreshing={isRefreshing} /> void error: string | null } @@ -18,6 +19,7 @@ export function Sidebar({ isConnected, isConnecting, sessionId, + isStateless, onConnect, error }: SidebarProps) { @@ -63,10 +65,22 @@ export function Sidebar({ )} - {isConnected && sessionId && ( + {isConnected && (
-
Session ID
- {sessionId.slice(0, 26)}... + {isStateless ? ( + <> +
Mode
+
+ + Stateless +
+ + ) : sessionId ? ( + <> +
Session ID
+ {sessionId.slice(0, 26)}... + + ) : null}
)} diff --git a/packages/inspector/src/components/ToolsPanel.css b/packages/inspector/src/components/ToolsPanel.css index b1b728f..3b3e3f7 100644 --- a/packages/inspector/src/components/ToolsPanel.css +++ b/packages/inspector/src/components/ToolsPanel.css @@ -11,7 +11,7 @@ display: flex; align-items: center; gap: 8px; - padding: 12px 16px; + padding: 8px 16px; background: var(--bg-tertiary); border-bottom: 1px solid var(--border-color); font-size: 13px; @@ -27,6 +27,37 @@ color: var(--text-secondary); } +.refresh-button { + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; +} + +.refresh-button:hover { + background: var(--bg-primary); + color: var(--text-primary); + border-color: var(--text-muted); +} + +.refresh-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.refresh-button.refreshing svg { + animation: spin 0.8s linear infinite; +} + .tools-content { flex: 1; overflow: hidden; diff --git a/packages/inspector/src/components/ToolsPanel.tsx b/packages/inspector/src/components/ToolsPanel.tsx index fa748c6..dd3de14 100644 --- a/packages/inspector/src/components/ToolsPanel.tsx +++ b/packages/inspector/src/components/ToolsPanel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Wrench, Play, Circle, CircleDot, Code2, FileJson } from 'lucide-react' +import { Wrench, Play, Circle, CircleDot, Code2, FileJson, RefreshCw } from 'lucide-react' import { Button } from './Button' import type { Tool } from '../App' import './ToolsPanel.css' @@ -9,8 +9,10 @@ interface ToolsPanelProps { selectedTool: Tool | null onSelectTool: (tool: Tool | null) => void onExecuteTool: (toolName: string, params: Record) => void + onRefreshTools: () => void isConnected: boolean isExecuting: boolean + isRefreshing: boolean } export function ToolsPanel({ @@ -18,8 +20,10 @@ export function ToolsPanel({ selectedTool, onSelectTool, onExecuteTool, + onRefreshTools, isConnected, - isExecuting + isExecuting, + isRefreshing }: ToolsPanelProps) { const [params, setParams] = useState>({}) const [jsonMode, setJsonMode] = useState(false) @@ -62,12 +66,14 @@ export function ToolsPanel({ const props = selectedTool.inputSchema?.properties || {} for (const [key, value] of Object.entries(params)) { - if (!value && !props[key]?.default) continue - const propType = props[key]?.type + // Skip empty optional fields (but always include if there's a value or it's required) + const isRequired = selectedTool.inputSchema?.required?.includes(key) + if (!value && !isRequired && !props[key]?.default) continue + // Try to parse as JSON for arrays/objects - if (value.startsWith('[') || value.startsWith('{')) { + if (value && (value.startsWith('[') || value.startsWith('{'))) { try { finalParams[key] = JSON.parse(value) continue @@ -78,7 +84,7 @@ export function ToolsPanel({ // Convert to appropriate type if (propType === 'number' || propType === 'integer') { - finalParams[key] = Number(value) + finalParams[key] = value ? Number(value) : undefined } else if (propType === 'boolean') { finalParams[key] = value === 'true' } else { @@ -128,6 +134,16 @@ export function ToolsPanel({ Tools {tools.length} + {isConnected && ( + + )}
diff --git a/packages/inspector/src/hooks/useMCP.ts b/packages/inspector/src/hooks/useMCP.ts index 81a08c2..3963072 100644 --- a/packages/inspector/src/hooks/useMCP.ts +++ b/packages/inspector/src/hooks/useMCP.ts @@ -5,10 +5,13 @@ interface UseMCPReturn { isConnected: boolean isConnecting: boolean sessionId: string | null + isStateless: boolean tools: Tool[] + isRefreshing: boolean error: string | null connect: (serverUrl: string) => Promise disconnect: () => void + refreshTools: () => Promise callTool: (toolName: string, params: Record) => Promise<{ textContent: string htmlContent: string | null @@ -43,10 +46,13 @@ export function useMCP(): UseMCPReturn { const [isConnected, setIsConnected] = useState(false) const [isConnecting, setIsConnecting] = useState(false) const [sessionId, setSessionId] = useState(null) + const [isStateless, setIsStateless] = useState(false) const [tools, setTools] = useState([]) + const [isRefreshing, setIsRefreshing] = useState(false) const [error, setError] = useState(null) - + const serverUrlRef = useRef('') + const sessionIdRef = useRef(null) const connect = useCallback(async (serverUrl: string) => { setIsConnecting(true) @@ -81,16 +87,28 @@ export function useMCP(): UseMCPReturn { throw new Error(initResult?.error?.message || 'Failed to initialize') } + // Track whether this is a stateless server (no session ID) + const serverIsStateless = !newSessionId setSessionId(newSessionId) + sessionIdRef.current = newSessionId + setIsStateless(serverIsStateless) + + // Helper to build headers (only include session ID if present) + const buildHeaders = () => { + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + } + if (newSessionId) { + headers['mcp-session-id'] = newSessionId + } + return headers + } // Send initialized notification await fetch(serverUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream', - 'mcp-session-id': newSessionId || '', - }, + headers: buildHeaders(), body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', @@ -100,11 +118,7 @@ export function useMCP(): UseMCPReturn { // List tools const toolsResponse = await fetch(serverUrl, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream', - 'mcp-session-id': newSessionId || '', - }, + headers: buildHeaders(), body: JSON.stringify({ jsonrpc: '2.0', id: 2, @@ -125,6 +139,7 @@ export function useMCP(): UseMCPReturn { setError(err instanceof Error ? err.message : 'Connection failed') setIsConnected(false) setSessionId(null) + setIsStateless(false) setTools([]) } finally { setIsConnecting(false) @@ -134,22 +149,64 @@ export function useMCP(): UseMCPReturn { const disconnect = useCallback(() => { setIsConnected(false) setSessionId(null) + sessionIdRef.current = null + setIsStateless(false) setTools([]) setError(null) }, []) + const refreshTools = useCallback(async () => { + if (!isConnected) return + + setIsRefreshing(true) + try { + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + } + if (sessionIdRef.current) { + headers['mcp-session-id'] = sessionIdRef.current + } + + const toolsResponse = await fetch(serverUrlRef.current, { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id: Date.now(), + method: 'tools/list', + params: {}, + }), + }) + + const toolsText = await toolsResponse.text() + const toolsResult = parseSSE(toolsText) as { result?: { tools: Tool[] }; error?: { message: string } } + + if (toolsResult?.result?.tools) { + setTools(toolsResult.result.tools) + } + } finally { + setIsRefreshing(false) + } + }, [isConnected]) + const callTool = useCallback(async (toolName: string, params: Record) => { - if (!sessionId) { + if (!isConnected) { throw new Error('Not connected') } + // Build headers - only include session ID if present (stateful server) + const headers: Record = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + } + if (sessionId) { + headers['mcp-session-id'] = sessionId + } + const response = await fetch(serverUrlRef.current, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream', - 'mcp-session-id': sessionId, - }, + headers, body: JSON.stringify({ jsonrpc: '2.0', id: Date.now(), @@ -195,16 +252,19 @@ export function useMCP(): UseMCPReturn { htmlContent, isError: false, } - }, [sessionId]) + }, [isConnected, sessionId]) return { isConnected, isConnecting, sessionId, + isStateless, tools, + isRefreshing, error, connect, disconnect, + refreshTools, callTool, } } diff --git a/packages/library/server/bundle.ts b/packages/library/server/bundle.ts index c203830..0aa8bc1 100644 --- a/packages/library/server/bundle.ts +++ b/packages/library/server/bundle.ts @@ -6,12 +6,8 @@ const bundleCache = new Map(); // In development mode, skip cache to allow hot-reloading of component changes const isDev = process.env.NODE_ENV !== 'production'; -export async function bundleComponent(entryPath: string): Promise { - if (!isDev && bundleCache.has(entryPath)) { - return bundleCache.get(entryPath)!; - } - - const result = await esbuild.build({ +async function runBuild(entryPath: string): Promise> { + return esbuild.build({ entryPoints: [entryPath], bundle: true, write: false, // No disk I/O - keep everything in memory @@ -22,8 +18,29 @@ export async function bundleComponent(entryPath: string): Promise { '.tsx': 'tsx', '.ts': 'ts', }, - minify: false, + minify: !isDev, }); +} + +export async function bundleComponent(entryPath: string): Promise { + if (!isDev && bundleCache.has(entryPath)) { + return bundleCache.get(entryPath)!; + } + + let result: esbuild.BuildResult<{ write: false }>; + + try { + result = await runBuild(entryPath); + } catch (error) { + // Handle "The service was stopped" error in serverless environments (Vercel, Lambda, etc.) + // This happens when the serverless runtime freezes/stops the esbuild subprocess. + // esbuild automatically restarts its service on the next call, so we just retry. + if (error instanceof Error && error.message.includes('service was stopped')) { + result = await runBuild(entryPath); + } else { + throw error; + } + } const bundledJS = result.outputFiles[0].text;