imrpove inspector

This commit is contained in:
Fredrik Jensen 2025-12-06 15:18:25 +01:00
parent 8fd8fd3f13
commit 76fbc07343
10 changed files with 209 additions and 45 deletions

View File

@ -25,7 +25,7 @@ import { createUI } from 'mcp-ui-kit/server';
const server = new McpServer({ name: 'my-server', version: '1.0.0' }); 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( server.registerTool(
'dashboard', 'dashboard',
@ -68,7 +68,11 @@ server.registerTool(
## Client Usage ## Client Usage
The file passed to `createUI()` must render a component to the `#root` element:
```tsx ```tsx
// StockDashboard.tsx
import { createRoot } from 'react-dom/client';
import { sendPrompt, callTool, useProps } from 'mcp-ui-kit/ui'; import { sendPrompt, callTool, useProps } from 'mcp-ui-kit/ui';
function StockDashboard() { function StockDashboard() {
@ -80,27 +84,35 @@ function StockDashboard() {
{symbols.map(symbol => ( {symbols.map(symbol => (
<div key={symbol}> <div key={symbol}>
<span>{symbol}</span> <span>{symbol}</span>
<button onClick={() => sendPrompt(`Analyze ${symbol} over ${timeframe}`)}>Analyze</button> <button onClick={() => sendPrompt(`Analyze ${symbol} over ${timeframe}`)}>
<button onClick={() => callTool('get_stock_price', { symbol })}>Refresh</button> Analyze
</button>
<button onClick={() => callTool('get_stock_price', { symbol })}>
Refresh
</button>
</div> </div>
))} ))}
</div> </div>
); );
} }
// Render to DOM
createRoot(document.getElementById('root')!).render(<StockDashboard />);
``` ```
## API ## API
### Server (`mcp-ui-kit/server`) ### 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?) }` - 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']` - `opts.frameSize`: `[width, height]` e.g. `['700px', '500px']`
```typescript ```typescript
createUI('dashboard', import.meta.resolve('./MyComponent.tsx')); // ESM createUI('dashboard', import.meta.resolve('./Dashboard.tsx')); // ESM
createUI('dashboard', require.resolve('./MyComponent.tsx')); // CommonJS createUI('dashboard', require.resolve('./Dashboard.tsx')); // CommonJS
``` ```
### UI (`mcp-ui-kit/ui`) ### UI (`mcp-ui-kit/ui`)

View File

@ -1,4 +1,3 @@
import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { StockDashboard } from './StockDashboard'; import { StockDashboard } from './StockDashboard';

View File

@ -34,9 +34,12 @@ function App() {
isConnected, isConnected,
isConnecting, isConnecting,
sessionId, sessionId,
isStateless,
tools, tools,
isRefreshing,
connect, connect,
disconnect, disconnect,
refreshTools,
callTool, callTool,
error error
} = useMCP() } = useMCP()
@ -113,6 +116,7 @@ function App() {
isConnected={isConnected} isConnected={isConnected}
isConnecting={isConnecting} isConnecting={isConnecting}
sessionId={sessionId} sessionId={sessionId}
isStateless={isStateless}
onConnect={handleConnect} onConnect={handleConnect}
error={error} error={error}
/> />
@ -124,8 +128,10 @@ function App() {
selectedTool={selectedTool} selectedTool={selectedTool}
onSelectTool={setSelectedTool} onSelectTool={setSelectedTool}
onExecuteTool={handleExecuteTool} onExecuteTool={handleExecuteTool}
onRefreshTools={refreshTools}
isConnected={isConnected} isConnected={isConnected}
isExecuting={isExecuting} isExecuting={isExecuting}
isRefreshing={isRefreshing}
/> />
<ResultsPane <ResultsPane

View File

@ -20,7 +20,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 16px; padding: 1.2px 16px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }

View File

@ -88,6 +88,15 @@
font-size: 12px; font-size: 12px;
} }
.stateless-badge {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent-orange, #f0883e);
font-size: 12px;
font-weight: 500;
}
.spinner { .spinner {
width: 14px; width: 14px;
height: 14px; height: 14px;

View File

@ -1,4 +1,4 @@
import { Server, Plug, PlugZap, AlertCircle } from 'lucide-react' import { Server, Plug, PlugZap, AlertCircle, Zap } from 'lucide-react'
import { Button } from './Button' import { Button } from './Button'
import './Sidebar.css' import './Sidebar.css'
@ -8,6 +8,7 @@ interface SidebarProps {
isConnected: boolean isConnected: boolean
isConnecting: boolean isConnecting: boolean
sessionId: string | null sessionId: string | null
isStateless: boolean
onConnect: () => void onConnect: () => void
error: string | null error: string | null
} }
@ -18,6 +19,7 @@ export function Sidebar({
isConnected, isConnected,
isConnecting, isConnecting,
sessionId, sessionId,
isStateless,
onConnect, onConnect,
error error
}: SidebarProps) { }: SidebarProps) {
@ -63,10 +65,22 @@ export function Sidebar({
</div> </div>
)} )}
{isConnected && sessionId && ( {isConnected && (
<div className="session-info"> <div className="session-info">
<div className="session-label">Session ID</div> {isStateless ? (
<code className="session-id">{sessionId.slice(0, 26)}...</code> <>
<div className="session-label">Mode</div>
<div className="stateless-badge">
<Zap size={12} />
<span>Stateless</span>
</div>
</>
) : sessionId ? (
<>
<div className="session-label">Session ID</div>
<code className="session-id">{sessionId.slice(0, 26)}...</code>
</>
) : null}
</div> </div>
)} )}
</div> </div>

View File

@ -11,7 +11,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 12px 16px; padding: 8px 16px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
font-size: 13px; font-size: 13px;
@ -27,6 +27,37 @@
color: var(--text-secondary); 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 { .tools-content {
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' 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 { Button } from './Button'
import type { Tool } from '../App' import type { Tool } from '../App'
import './ToolsPanel.css' import './ToolsPanel.css'
@ -9,8 +9,10 @@ interface ToolsPanelProps {
selectedTool: Tool | null selectedTool: Tool | null
onSelectTool: (tool: Tool | null) => void onSelectTool: (tool: Tool | null) => void
onExecuteTool: (toolName: string, params: Record<string, unknown>) => void onExecuteTool: (toolName: string, params: Record<string, unknown>) => void
onRefreshTools: () => void
isConnected: boolean isConnected: boolean
isExecuting: boolean isExecuting: boolean
isRefreshing: boolean
} }
export function ToolsPanel({ export function ToolsPanel({
@ -18,8 +20,10 @@ export function ToolsPanel({
selectedTool, selectedTool,
onSelectTool, onSelectTool,
onExecuteTool, onExecuteTool,
onRefreshTools,
isConnected, isConnected,
isExecuting isExecuting,
isRefreshing
}: ToolsPanelProps) { }: ToolsPanelProps) {
const [params, setParams] = useState<Record<string, string>>({}) const [params, setParams] = useState<Record<string, string>>({})
const [jsonMode, setJsonMode] = useState(false) const [jsonMode, setJsonMode] = useState(false)
@ -62,12 +66,14 @@ export function ToolsPanel({
const props = selectedTool.inputSchema?.properties || {} const props = selectedTool.inputSchema?.properties || {}
for (const [key, value] of Object.entries(params)) { for (const [key, value] of Object.entries(params)) {
if (!value && !props[key]?.default) continue
const propType = props[key]?.type 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 // Try to parse as JSON for arrays/objects
if (value.startsWith('[') || value.startsWith('{')) { if (value && (value.startsWith('[') || value.startsWith('{'))) {
try { try {
finalParams[key] = JSON.parse(value) finalParams[key] = JSON.parse(value)
continue continue
@ -78,7 +84,7 @@ export function ToolsPanel({
// Convert to appropriate type // Convert to appropriate type
if (propType === 'number' || propType === 'integer') { if (propType === 'number' || propType === 'integer') {
finalParams[key] = Number(value) finalParams[key] = value ? Number(value) : undefined
} else if (propType === 'boolean') { } else if (propType === 'boolean') {
finalParams[key] = value === 'true' finalParams[key] = value === 'true'
} else { } else {
@ -128,6 +134,16 @@ export function ToolsPanel({
<Wrench size={16} /> <Wrench size={16} />
<span>Tools</span> <span>Tools</span>
<span className="tool-count">{tools.length}</span> <span className="tool-count">{tools.length}</span>
{isConnected && (
<button
className={`refresh-button ${isRefreshing ? 'refreshing' : ''}`}
onClick={onRefreshTools}
disabled={isRefreshing}
title="Refresh tools"
>
<RefreshCw size={14} />
</button>
)}
</div> </div>
<div className="tools-content"> <div className="tools-content">

View File

@ -5,10 +5,13 @@ interface UseMCPReturn {
isConnected: boolean isConnected: boolean
isConnecting: boolean isConnecting: boolean
sessionId: string | null sessionId: string | null
isStateless: boolean
tools: Tool[] tools: Tool[]
isRefreshing: boolean
error: string | null error: string | null
connect: (serverUrl: string) => Promise<void> connect: (serverUrl: string) => Promise<void>
disconnect: () => void disconnect: () => void
refreshTools: () => Promise<void>
callTool: (toolName: string, params: Record<string, unknown>) => Promise<{ callTool: (toolName: string, params: Record<string, unknown>) => Promise<{
textContent: string textContent: string
htmlContent: string | null htmlContent: string | null
@ -43,10 +46,13 @@ export function useMCP(): UseMCPReturn {
const [isConnected, setIsConnected] = useState(false) const [isConnected, setIsConnected] = useState(false)
const [isConnecting, setIsConnecting] = useState(false) const [isConnecting, setIsConnecting] = useState(false)
const [sessionId, setSessionId] = useState<string | null>(null) const [sessionId, setSessionId] = useState<string | null>(null)
const [isStateless, setIsStateless] = useState(false)
const [tools, setTools] = useState<Tool[]>([]) const [tools, setTools] = useState<Tool[]>([])
const [isRefreshing, setIsRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const serverUrlRef = useRef<string>('') const serverUrlRef = useRef<string>('')
const sessionIdRef = useRef<string | null>(null)
const connect = useCallback(async (serverUrl: string) => { const connect = useCallback(async (serverUrl: string) => {
setIsConnecting(true) setIsConnecting(true)
@ -81,16 +87,28 @@ export function useMCP(): UseMCPReturn {
throw new Error(initResult?.error?.message || 'Failed to initialize') throw new Error(initResult?.error?.message || 'Failed to initialize')
} }
// Track whether this is a stateless server (no session ID)
const serverIsStateless = !newSessionId
setSessionId(newSessionId) setSessionId(newSessionId)
sessionIdRef.current = newSessionId
setIsStateless(serverIsStateless)
// Helper to build headers (only include session ID if present)
const buildHeaders = () => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
}
if (newSessionId) {
headers['mcp-session-id'] = newSessionId
}
return headers
}
// Send initialized notification // Send initialized notification
await fetch(serverUrl, { await fetch(serverUrl, {
method: 'POST', method: 'POST',
headers: { headers: buildHeaders(),
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'mcp-session-id': newSessionId || '',
},
body: JSON.stringify({ body: JSON.stringify({
jsonrpc: '2.0', jsonrpc: '2.0',
method: 'notifications/initialized', method: 'notifications/initialized',
@ -100,11 +118,7 @@ export function useMCP(): UseMCPReturn {
// List tools // List tools
const toolsResponse = await fetch(serverUrl, { const toolsResponse = await fetch(serverUrl, {
method: 'POST', method: 'POST',
headers: { headers: buildHeaders(),
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'mcp-session-id': newSessionId || '',
},
body: JSON.stringify({ body: JSON.stringify({
jsonrpc: '2.0', jsonrpc: '2.0',
id: 2, id: 2,
@ -125,6 +139,7 @@ export function useMCP(): UseMCPReturn {
setError(err instanceof Error ? err.message : 'Connection failed') setError(err instanceof Error ? err.message : 'Connection failed')
setIsConnected(false) setIsConnected(false)
setSessionId(null) setSessionId(null)
setIsStateless(false)
setTools([]) setTools([])
} finally { } finally {
setIsConnecting(false) setIsConnecting(false)
@ -134,22 +149,64 @@ export function useMCP(): UseMCPReturn {
const disconnect = useCallback(() => { const disconnect = useCallback(() => {
setIsConnected(false) setIsConnected(false)
setSessionId(null) setSessionId(null)
sessionIdRef.current = null
setIsStateless(false)
setTools([]) setTools([])
setError(null) setError(null)
}, []) }, [])
const refreshTools = useCallback(async () => {
if (!isConnected) return
setIsRefreshing(true)
try {
const headers: Record<string, string> = {
'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<string, unknown>) => { const callTool = useCallback(async (toolName: string, params: Record<string, unknown>) => {
if (!sessionId) { if (!isConnected) {
throw new Error('Not connected') throw new Error('Not connected')
} }
// Build headers - only include session ID if present (stateful server)
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
}
if (sessionId) {
headers['mcp-session-id'] = sessionId
}
const response = await fetch(serverUrlRef.current, { const response = await fetch(serverUrlRef.current, {
method: 'POST', method: 'POST',
headers: { headers,
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'mcp-session-id': sessionId,
},
body: JSON.stringify({ body: JSON.stringify({
jsonrpc: '2.0', jsonrpc: '2.0',
id: Date.now(), id: Date.now(),
@ -195,16 +252,19 @@ export function useMCP(): UseMCPReturn {
htmlContent, htmlContent,
isError: false, isError: false,
} }
}, [sessionId]) }, [isConnected, sessionId])
return { return {
isConnected, isConnected,
isConnecting, isConnecting,
sessionId, sessionId,
isStateless,
tools, tools,
isRefreshing,
error, error,
connect, connect,
disconnect, disconnect,
refreshTools,
callTool, callTool,
} }
} }

View File

@ -6,12 +6,8 @@ const bundleCache = new Map<string, string>();
// In development mode, skip cache to allow hot-reloading of component changes // In development mode, skip cache to allow hot-reloading of component changes
const isDev = process.env.NODE_ENV !== 'production'; const isDev = process.env.NODE_ENV !== 'production';
export async function bundleComponent(entryPath: string): Promise<string> { async function runBuild(entryPath: string): Promise<esbuild.BuildResult<{ write: false }>> {
if (!isDev && bundleCache.has(entryPath)) { return esbuild.build({
return bundleCache.get(entryPath)!;
}
const result = await esbuild.build({
entryPoints: [entryPath], entryPoints: [entryPath],
bundle: true, bundle: true,
write: false, // No disk I/O - keep everything in memory write: false, // No disk I/O - keep everything in memory
@ -22,8 +18,29 @@ export async function bundleComponent(entryPath: string): Promise<string> {
'.tsx': 'tsx', '.tsx': 'tsx',
'.ts': 'ts', '.ts': 'ts',
}, },
minify: false, minify: !isDev,
}); });
}
export async function bundleComponent(entryPath: string): Promise<string> {
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; const bundledJS = result.outputFiles[0].text;