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 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 => (
<div key={symbol}>
<span>{symbol}</span>
<button onClick={() => sendPrompt(`Analyze ${symbol} over ${timeframe}`)}>Analyze</button>
<button onClick={() => callTool('get_stock_price', { symbol })}>Refresh</button>
<button onClick={() => sendPrompt(`Analyze ${symbol} over ${timeframe}`)}>
Analyze
</button>
<button onClick={() => callTool('get_stock_price', { symbol })}>
Refresh
</button>
</div>
))}
</div>
);
}
// Render to DOM
createRoot(document.getElementById('root')!).render(<StockDashboard />);
```
## 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`)

View File

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

View File

@ -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}
/>
<ResultsPane

View File

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

View File

@ -88,6 +88,15 @@
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 {
width: 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 './Sidebar.css'
@ -8,6 +8,7 @@ interface SidebarProps {
isConnected: boolean
isConnecting: boolean
sessionId: string | null
isStateless: boolean
onConnect: () => 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({
</div>
)}
{isConnected && sessionId && (
{isConnected && (
<div className="session-info">
<div className="session-label">Session ID</div>
<code className="session-id">{sessionId.slice(0, 26)}...</code>
{isStateless ? (
<>
<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>

View File

@ -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;

View File

@ -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<string, unknown>) => 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<Record<string, string>>({})
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({
<Wrench size={16} />
<span>Tools</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 className="tools-content">

View File

@ -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<void>
disconnect: () => void
refreshTools: () => Promise<void>
callTool: (toolName: string, params: Record<string, unknown>) => 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<string | null>(null)
const [isStateless, setIsStateless] = useState(false)
const [tools, setTools] = useState<Tool[]>([])
const [isRefreshing, setIsRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null)
const serverUrlRef = useRef<string>('')
const sessionIdRef = useRef<string | null>(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<string, string> = {
'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<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>) => {
if (!sessionId) {
if (!isConnected) {
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, {
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,
}
}

View File

@ -6,12 +6,8 @@ const bundleCache = new Map<string, string>();
// 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<string> {
if (!isDev && bundleCache.has(entryPath)) {
return bundleCache.get(entryPath)!;
}
const result = await esbuild.build({
async function runBuild(entryPath: string): Promise<esbuild.BuildResult<{ write: false }>> {
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<string> {
'.tsx': 'tsx',
'.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;