imrpove inspector
This commit is contained in:
parent
8fd8fd3f13
commit
76fbc07343
26
README.md
26
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 => (
|
||||
<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`)
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { StockDashboard } from './StockDashboard';
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user