first commit

This commit is contained in:
Fredrik Jensen 2025-12-05 21:06:27 +01:00
commit b2f633699f
39 changed files with 7202 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.env
.DS_Store

162
README.md Normal file
View File

@ -0,0 +1,162 @@
# MCP-UI Weather Dashboard Server
A clean example of an MCP server that provides an interactive weather dashboard UI using React and remoteDom.
## Architecture
```
┌─────────────────────────┐
│ React Component (TSX) │
│ server/components/ │
└───────────┬─────────────┘
▼ esbuild bundles
┌─────────────────────────┐
│ Bundle (JS) │
│ dist/ │
└───────────┬─────────────┘
▼ loaded by server
┌─────────────────────────┐
│ MCP Server │
│ Creates remoteDom │
│ resource │
└───────────┬─────────────┘
▼ MCP protocol
┌─────────────────────────┐
│ Client (nanobot, etc.) │
│ Renders in sandbox │
│ with React provided │
└─────────────────────────┘
```
## Setup
### Install Dependencies
```bash
npm install
```
### Build the UI Bundle
```bash
npm run build:ui
```
This bundles the React component using esbuild:
- Input: `server/components/index.tsx`
- Output: `dist/weather-dashboard.bundle.js`
### Start the Server
```bash
npm start
```
This will:
1. Build the UI bundle
2. Start the MCP server on `http://localhost:3000/mcp`
### Development with Auto-reload
```bash
npm run watch
```
Rebuilds UI and restarts server on file changes.
## How It Works
### 1. React Component (`server/components/WeatherDashboard.tsx`)
- Written in modern React with hooks, JSX, TypeScript
- Includes interactive city selection, weather data, forecast
- Uses `postMessage` to send data back to chat via MCP-UI protocol
### 2. Build Step (`build-ui.mjs`)
- Uses esbuild to bundle the React component
- Transpiles JSX → JavaScript
- Bundles imports (except React/ReactDOM which host provides)
- Output: Single IIFE bundle
### 3. Server (`server/ui.ts`)
- Reads the bundled JavaScript
- Creates a `remoteDom` UI resource with MIME type:
`application/vnd.mcp-ui.remote-dom+javascript; framework=react`
- Server sends this to clients via MCP protocol
### 4. Client Rendering
- Client (nanobot, etc.) receives the bundle as text
- Provides React/ReactDOM in a sandboxed environment
- Executes the bundle, which renders the component
- Handles `postMessage` events from the UI
## Available Scripts
- `npm run build:ui` - Build the React UI bundle
- `npm start` - Build UI + start server
- `npm run watch` - Development mode with auto-reload
- `npm run nanobot` - Start nanobot client
## Key Files
- `server/components/WeatherDashboard.tsx` - Main React component
- `server/components/index.tsx` - Entry point for bundle
- `build-ui.mjs` - esbuild configuration
- `server/ui.ts` - Creates MCP-UI resource
- `server/index.ts` - MCP server setup
- `dist/weather-dashboard.bundle.js` - Generated bundle
## remoteDom vs rawHtml
### remoteDom (current)
- ✅ Full React with hooks, state management
- ✅ Better component architecture
- ✅ Type safety with TypeScript
- ⚠️ Requires client support for remoteDom
- ⚠️ Requires build step
### rawHtml (alternative)
- ✅ Works everywhere
- ✅ No build step needed
- ⚠️ Vanilla JS only
- ⚠️ Manual DOM manipulation
## Testing
### With Nanobot
```bash
npm run nanobot
```
Then in the nanobot chat:
```
Can you show me the weather dashboard?
```
Or directly call the tool:
```
weather_dashboard
```
### Features
The UI allows:
- Switching between cities (Oslo, San Francisco, New York)
- Viewing current weather and 5-day forecast
- Sending weather data back to chat with "Send to Chat" button
## Project Structure
```
mcp-ui/
├── server/
│ ├── components/
│ │ ├── WeatherDashboard.tsx # React component
│ │ └── index.tsx # Entry point
│ ├── index.ts # MCP server
│ └── ui.ts # UI resource definition
├── dist/
│ └── weather-dashboard.bundle.js # Generated bundle
├── build-ui.mjs # esbuild configuration
├── nanobot.yaml # Nanobot configuration
├── package.json
└── README.md
```

BIN
nanobot.db Normal file

Binary file not shown.

9
nanobot.yaml Normal file
View File

@ -0,0 +1,9 @@
agents:
dealer:
name: Weather Dashboard
model: gpt-3.5-turbo
mcpServers: weatherServer
mcpServers:
weatherServer:
url: http://localhost:3000/mcp

4101
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "mcp-ui",
"version": "1.0.0",
"private": true,
"type": "module",
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "npm run dev --workspace=@mcp-ui/inspector",
"dev:server": "npm run dev --workspace=@mcp-ui/demo-server",
"dev:library": "npm run dev --workspace=@mcp-ui/library",
"dev:all": "npm run dev:server & npm run dev:library & npm run dev",
"build": "npm run build --workspace=@mcp-ui/inspector",
"start": "npm run start --workspace=@mcp-ui/demo-server",
"watch": "npm run watch --workspace=@mcp-ui/demo-server",
"watch:library": "npm run watch --workspace=@mcp-ui/library",
"watch:inspector": "npm run watch --workspace=@mcp-ui/inspector",
"inspector": "npx @modelcontextprotocol/inspector",
"nanobot": "dotenv -e .env -- nanobot run ./nanobot.yaml"
},
"devDependencies": {
"dotenv-cli": "^11.0.0",
"typescript": "^5.6.3"
}
}

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MCP UI Inspector</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,24 @@
{
"name": "@mcp-ui/inspector",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"watch": "tsc --watch"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"lucide-react": "^0.460.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^6.0.0"
}
}

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#a371f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7L12 12L22 7L12 2Z"/>
<path d="M2 17L12 22L22 17"/>
<path d="M2 12L12 17L22 12"/>
</svg>

After

Width:  |  Height:  |  Size: 289 B

View File

@ -0,0 +1,99 @@
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.header-brand {
display: flex;
align-items: center;
gap: 12px;
color: var(--text-primary);
}
.header-brand svg {
color: var(--accent-purple);
}
.header-brand h1 {
font-size: 18px;
font-weight: 600;
}
.header-status {
display: flex;
align-items: center;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.status-badge.connected {
background: rgba(63, 185, 80, 0.15);
color: var(--accent-green);
}
.status-badge.disconnected {
background: rgba(139, 148, 158, 0.15);
color: var(--text-secondary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.status-badge.connected .status-dot {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Main layout */
.main-layout {
display: flex;
flex: 1;
overflow: hidden;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content-panels {
display: flex;
flex: 1;
overflow: hidden;
}
/* Responsive */
@media (max-width: 1200px) {
.content-panels {
flex-direction: column;
}
}

View File

@ -0,0 +1,138 @@
import { useState, useCallback } from 'react'
import { Sidebar } from './components/Sidebar'
import { ToolsPanel } from './components/ToolsPanel'
import { ResultsPane } from './components/ResultsPane'
import { useMCP } from './hooks/useMCP'
import './App.css'
export type Tool = {
name: string
description?: string
inputSchema?: {
type?: string
properties?: Record<string, {
type?: string
description?: string
default?: unknown
enum?: string[]
}>
required?: string[]
}
}
export type ToolResult = {
textContent: string
htmlContent: string | null
isError: boolean
timestamp: Date
}
function App() {
const [serverUrl, setServerUrl] = useState('http://localhost:3000/mcp')
const {
isConnected,
isConnecting,
sessionId,
tools,
connect,
disconnect,
callTool,
error
} = useMCP()
const [selectedTool, setSelectedTool] = useState<Tool | null>(null)
const [toolResult, setToolResult] = useState<ToolResult | null>(null)
const [isExecuting, setIsExecuting] = useState(false)
const handleConnect = useCallback(async () => {
if (isConnected) {
disconnect()
setSelectedTool(null)
setToolResult(null)
} else {
await connect(serverUrl)
}
}, [isConnected, connect, disconnect, serverUrl])
const handleExecuteTool = useCallback(async (toolName: string, params: Record<string, unknown>) => {
setIsExecuting(true)
setToolResult(null)
try {
const result = await callTool(toolName, params)
setToolResult({
...result,
timestamp: new Date()
})
} catch (err) {
setToolResult({
textContent: err instanceof Error ? err.message : 'Unknown error',
htmlContent: null,
isError: true,
timestamp: new Date()
})
} finally {
setIsExecuting(false)
}
}, [callTool])
return (
<div className="app">
<header className="header">
<div className="header-brand">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<h1>MCP UI Inspector</h1>
</div>
<div className="header-status">
{isConnected ? (
<span className="status-badge connected">
<span className="status-dot"></span>
Connected
</span>
) : (
<span className="status-badge disconnected">
<span className="status-dot"></span>
Disconnected
</span>
)}
</div>
</header>
<div className="main-layout">
<Sidebar
serverUrl={serverUrl}
onServerUrlChange={setServerUrl}
isConnected={isConnected}
isConnecting={isConnecting}
sessionId={sessionId}
onConnect={handleConnect}
error={error}
/>
<main className="content">
<div className="content-panels">
<ToolsPanel
tools={tools}
selectedTool={selectedTool}
onSelectTool={setSelectedTool}
onExecuteTool={handleExecuteTool}
isConnected={isConnected}
isExecuting={isExecuting}
/>
<ResultsPane
result={toolResult}
isExecuting={isExecuting}
/>
</div>
</main>
</div>
</div>
)
}
export default App

View File

@ -0,0 +1,71 @@
/* Button Component Styles */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
background: var(--bg-hover);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Primary Variant */
.btn-primary {
background: var(--accent-blue);
border-color: var(--accent-blue);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #4c9aed;
border-color: #4c9aed;
}
/* Ghost Variant (icon buttons) */
.btn-ghost {
background: transparent;
border-color: transparent;
padding: 6px;
}
.btn-ghost:hover:not(:disabled) {
background: var(--bg-hover);
border-color: transparent;
}
/* Loading State */
.btn-loading {
cursor: wait;
}
.btn .spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,50 @@
import type { ReactNode, ButtonHTMLAttributes } from 'react'
import './Button.css'
type ButtonVariant = 'default' | 'primary' | 'ghost'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
loading?: boolean
loadingText?: string
icon?: ReactNode
children?: ReactNode
}
export function Button({
variant = 'default',
loading = false,
loadingText,
icon,
children,
className = '',
disabled,
...props
}: ButtonProps) {
const classes = [
'btn',
`btn-${variant}`,
loading ? 'btn-loading' : '',
className
].filter(Boolean).join(' ')
return (
<button
className={classes}
disabled={disabled || loading}
{...props}
>
{loading ? (
<>
<span className="spinner"></span>
{loadingText || children}
</>
) : (
<>
{icon}
{children}
</>
)}
</button>
)
}

View File

@ -0,0 +1,184 @@
.results-pane {
flex: 1;
display: flex;
flex-direction: column;
min-width: 400px;
background: var(--bg-primary);
}
.results-pane.fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
min-width: 0;
}
.results-pane .panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.results-tabs {
display: flex;
gap: 0;
}
.tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 12px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.tab:hover {
color: var(--text-primary);
}
.tab.active {
color: var(--accent-blue);
border-bottom-color: var(--accent-blue);
}
.tab-badge {
font-size: 10px;
color: var(--accent-green);
}
.results-actions {
display: flex;
align-items: center;
gap: 12px;
}
.timestamp {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-muted);
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: 4px;
}
.icon-button:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.results-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.results-loading,
.results-empty,
.results-error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-muted);
text-align: center;
padding: 40px;
}
.results-loading p,
.results-empty p {
font-size: 14px;
}
.results-empty span {
font-size: 12px;
color: var(--text-muted);
}
.results-error {
color: var(--accent-red);
}
.results-error p {
font-size: 14px;
font-weight: 600;
}
.results-error pre {
margin-top: 8px;
padding: 12px 16px;
background: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.3);
border-radius: 6px;
font-size: 12px;
max-width: 100%;
overflow-x: auto;
text-align: left;
}
.loader {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-blue);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.ui-frame {
flex: 1;
width: 100%;
border: none;
background: #fff;
}
.text-output {
flex: 1;
overflow: auto;
padding: 16px;
}
.text-output pre {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 1200px) {
.results-pane {
min-width: 0;
}
}

View File

@ -0,0 +1,99 @@
import { useState } from 'react'
import { Monitor, FileText, Clock, AlertTriangle, Maximize2, Minimize2 } from 'lucide-react'
import { Button } from './Button'
import type { ToolResult } from '../App'
import './ResultsPane.css'
interface ResultsPaneProps {
result: ToolResult | null
isExecuting: boolean
}
type Tab = 'ui' | 'text'
export function ResultsPane({ result, isExecuting }: ResultsPaneProps) {
const [activeTab, setActiveTab] = useState<Tab>('ui')
const [isFullscreen, setIsFullscreen] = useState(false)
const hasUI = result?.htmlContent != null
const hasText = result?.textContent != null
return (
<div className={`results-pane ${isFullscreen ? 'fullscreen' : ''}`}>
<div className="panel-header">
<div className="results-tabs">
<Button
className={`tab ${activeTab === 'ui' ? 'active' : ''}`}
onClick={() => setActiveTab('ui')}
icon={<Monitor size={14} />}
>
UI Output
{hasUI && <span className="tab-badge"></span>}
</Button>
<Button
className={`tab ${activeTab === 'text' ? 'active' : ''}`}
onClick={() => setActiveTab('text')}
icon={<FileText size={14} />}
>
Text Response
{hasText && <span className="tab-badge"></span>}
</Button>
</div>
<div className="results-actions">
{result?.timestamp && (
<span className="timestamp">
<Clock size={12} />
{result.timestamp.toLocaleTimeString()}
</span>
)}
<Button
variant="ghost"
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
icon={isFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
/>
</div>
</div>
<div className="results-content">
{isExecuting ? (
<div className="results-loading">
<div className="loader"></div>
<p>Executing tool...</p>
</div>
) : !result ? (
<div className="results-empty">
<Monitor size={48} strokeWidth={1} />
<p>Execute a tool to see results</p>
</div>
) : result.isError ? (
<div className="results-error">
<AlertTriangle size={24} />
<p>Error</p>
<pre>{result.textContent}</pre>
</div>
) : activeTab === 'ui' ? (
hasUI ? (
<iframe
className="ui-frame"
srcDoc={result.htmlContent!}
title="Tool UI Output"
sandbox="allow-scripts allow-same-origin"
/>
) : (
<div className="results-empty">
<Monitor size={48} strokeWidth={1} />
<p>No UI output returned</p>
<span>This tool may only return text content</span>
</div>
)
) : (
<div className="text-output">
<pre>{result.textContent || 'No text content'}</pre>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,125 @@
.sidebar {
width: 280px;
min-width: 280px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-section {
border-bottom: 1px solid var(--border-color);
}
.sidebar-section:last-child {
border-bottom: none;
margin-top: auto;
}
.sidebar-section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
background: var(--bg-tertiary);
}
.sidebar-section-content {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input {
width: 100%;
}
.error-message {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
background: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.3);
border-radius: 6px;
color: var(--accent-red);
font-size: 12px;
}
.error-message svg {
flex-shrink: 0;
margin-top: 1px;
}
.session-info {
padding: 10px 12px;
background: var(--bg-tertiary);
border-radius: 6px;
}
.session-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 4px;
}
.session-id {
color: var(--accent-green);
font-size: 12px;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.sidebar-about {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
}
.sidebar-links {
display: flex;
flex-direction: column;
gap: 4px;
}
.sidebar-links a {
font-size: 13px;
color: var(--accent-blue);
text-decoration: none;
}
.sidebar-links a:hover {
text-decoration: underline;
}

View File

@ -0,0 +1,88 @@
import { Server, Plug, PlugZap, AlertCircle } from 'lucide-react'
import { Button } from './Button'
import './Sidebar.css'
interface SidebarProps {
serverUrl: string
onServerUrlChange: (url: string) => void
isConnected: boolean
isConnecting: boolean
sessionId: string | null
onConnect: () => void
error: string | null
}
export function Sidebar({
serverUrl,
onServerUrlChange,
isConnected,
isConnecting,
sessionId,
onConnect,
error
}: SidebarProps) {
return (
<aside className="sidebar">
<div className="sidebar-section">
<div className="sidebar-section-header">
<Server size={16} />
<span>Server Connection</span>
</div>
<div className="sidebar-section-content">
<div className="form-group">
<label htmlFor="server-url">Server URL</label>
<input
id="server-url"
type="text"
value={serverUrl}
onChange={(e) => onServerUrlChange(e.target.value)}
placeholder="http://localhost:3000/mcp"
disabled={isConnected}
/>
</div>
<Button
variant={isConnected ? 'default' : 'primary'}
onClick={onConnect}
loading={isConnecting}
loadingText="Connecting..."
icon={isConnected ? <PlugZap size={16} /> : <Plug size={16} />}
>
{isConnected ? 'Disconnect' : 'Connect'}
</Button>
{error && (
<div className="error-message">
<AlertCircle size={14} />
<span>{error}</span>
</div>
)}
{isConnected && sessionId && (
<div className="session-info">
<div className="session-label">Session ID</div>
<code className="session-id">{sessionId.slice(0, 8)}...</code>
</div>
)}
</div>
</div>
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>About</span>
</div>
<div className="sidebar-section-content">
<p className="sidebar-about">
MCP UI Inspector is a developer tool for testing MCP servers with UI capabilities.
</p>
<div className="sidebar-links">
<a href="https://modelcontextprotocol.io" target="_blank" rel="noopener noreferrer">
MCP Documentation
</a>
</div>
</div>
</div>
</aside>
)
}

View File

@ -0,0 +1,252 @@
.tools-panel {
flex: 1;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
min-width: 400px;
max-width: 50%;
}
.panel-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
font-size: 13px;
font-weight: 600;
}
.tool-count {
background: var(--bg-hover);
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
}
.tools-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.tools-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: var(--text-muted);
text-align: center;
}
.tools-layout {
display: flex;
flex: 1;
overflow: hidden;
}
.tools-list {
width: 200px;
min-width: 200px;
border-right: 1px solid var(--border-color);
overflow-y: auto;
}
.tool-item {
width: 100%;
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color);
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.tool-item:hover {
background: var(--bg-tertiary);
}
.tool-item.selected {
background: var(--bg-tertiary);
}
.tool-item.selected .tool-chevron {
transform: rotate(90deg);
color: var(--accent-blue);
}
.tool-chevron {
flex-shrink: 0;
color: var(--text-muted);
margin-top: 2px;
transition: transform 0.15s;
}
.tool-info {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.tool-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.tool-description {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tool-detail {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tool-detail-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.tool-detail-header h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
}
.tool-detail-header p {
font-size: 13px;
color: var(--text-secondary);
}
.tool-params {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.params-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.json-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
font-size: 11px;
text-transform: none;
letter-spacing: 0;
}
.json-editor {
width: 100%;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 12px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 12px;
color: var(--text-primary);
resize: vertical;
}
.json-editor:focus {
outline: none;
border-color: var(--accent-blue);
}
.params-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.param-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.param-field label {
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
}
.param-field .required {
color: var(--accent-red);
margin-left: 2px;
}
.param-description {
font-size: 11px;
color: var(--text-muted);
}
.param-field input,
.param-field select {
width: 100%;
}
.no-params {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
}
.tool-actions {
padding: 16px;
border-top: 1px solid var(--border-color);
background: var(--bg-secondary);
}
.tool-actions button {
width: 100%;
justify-content: center;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 1200px) {
.tools-panel {
max-width: none;
border-right: none;
border-bottom: 1px solid var(--border-color);
}
}

View File

@ -0,0 +1,234 @@
import { useState, useEffect } from 'react'
import { Wrench, Play, ChevronRight, Code2, FileJson } from 'lucide-react'
import { Button } from './Button'
import type { Tool } from '../App'
import './ToolsPanel.css'
interface ToolsPanelProps {
tools: Tool[]
selectedTool: Tool | null
onSelectTool: (tool: Tool | null) => void
onExecuteTool: (toolName: string, params: Record<string, unknown>) => void
isConnected: boolean
isExecuting: boolean
}
export function ToolsPanel({
tools,
selectedTool,
onSelectTool,
onExecuteTool,
isConnected,
isExecuting
}: ToolsPanelProps) {
const [params, setParams] = useState<Record<string, string>>({})
const [jsonMode, setJsonMode] = useState(false)
const [jsonInput, setJsonInput] = useState('{}')
// Reset params when tool changes
useEffect(() => {
if (selectedTool) {
const defaultParams: Record<string, string> = {}
const props = selectedTool.inputSchema?.properties || {}
for (const [key, value] of Object.entries(props)) {
if (value.default !== undefined) {
defaultParams[key] = typeof value.default === 'string'
? value.default
: JSON.stringify(value.default)
} else {
defaultParams[key] = ''
}
}
setParams(defaultParams)
setJsonInput(JSON.stringify(defaultParams, null, 2))
}
}, [selectedTool])
const handleExecute = () => {
if (!selectedTool) return
let finalParams: Record<string, unknown> = {}
if (jsonMode) {
try {
finalParams = JSON.parse(jsonInput)
} catch {
alert('Invalid JSON')
return
}
} else {
const props = selectedTool.inputSchema?.properties || {}
for (const [key, value] of Object.entries(params)) {
if (!value && !props[key]?.default) continue
const propType = props[key]?.type
// Try to parse as JSON for arrays/objects
if (value.startsWith('[') || value.startsWith('{')) {
try {
finalParams[key] = JSON.parse(value)
continue
} catch {
// Fall through to string
}
}
// Convert to appropriate type
if (propType === 'number' || propType === 'integer') {
finalParams[key] = Number(value)
} else if (propType === 'boolean') {
finalParams[key] = value === 'true'
} else {
finalParams[key] = value
}
}
}
onExecuteTool(selectedTool.name, finalParams)
}
const renderParamInput = (name: string, schema: {
type?: string
description?: string
default?: unknown
enum?: string[]
} | undefined) => {
if (schema?.enum) {
return (
<select
value={params[name] || ''}
onChange={(e) => setParams({ ...params, [name]: e.target.value })}
>
<option value="">Select...</option>
{schema.enum.map((opt: string) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
)
}
const isArray = schema?.type === 'array'
return (
<input
type="text"
value={params[name] || ''}
onChange={(e) => setParams({ ...params, [name]: e.target.value })}
placeholder={isArray ? '["item1", "item2"]' : schema?.type || 'string'}
/>
)
}
return (
<div className="tools-panel">
<div className="panel-header">
<Wrench size={16} />
<span>Tools</span>
<span className="tool-count">{tools.length}</span>
</div>
<div className="tools-content">
{!isConnected ? (
<div className="tools-empty">
<p>Connect to a server to view available tools</p>
</div>
) : tools.length === 0 ? (
<div className="tools-empty">
<p>No tools available</p>
</div>
) : (
<div className="tools-layout">
<div className="tools-list">
{tools.map((tool) => (
<button
key={tool.name}
className={`tool-item ${selectedTool?.name === tool.name ? 'selected' : ''}`}
onClick={() => onSelectTool(tool)}
>
<ChevronRight size={14} className="tool-chevron" />
<div className="tool-info">
<span className="tool-name">{tool.name}</span>
{tool.description && (
<span className="tool-description">{tool.description}</span>
)}
</div>
</button>
))}
</div>
{selectedTool && (
<div className="tool-detail">
<div className="tool-detail-header">
<h3>{selectedTool.name}</h3>
{selectedTool.description && (
<p>{selectedTool.description}</p>
)}
</div>
<div className="tool-params">
<div className="params-header">
<span>Parameters</span>
<Button
className="json-toggle"
onClick={() => setJsonMode(!jsonMode)}
title={jsonMode ? 'Switch to form' : 'Switch to JSON'}
icon={jsonMode ? <Code2 size={14} /> : <FileJson size={14} />}
>
{jsonMode ? 'Form' : 'JSON'}
</Button>
</div>
{jsonMode ? (
<textarea
className="json-editor"
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
placeholder='{"key": "value"}'
rows={8}
/>
) : (
<div className="params-form">
{Object.entries(selectedTool.inputSchema?.properties || {}).map(([name, schema]) => (
<div key={name} className="param-field">
<label>
{name}
{selectedTool.inputSchema?.required?.includes(name) && (
<span className="required">*</span>
)}
</label>
{schema?.description && (
<span className="param-description">{schema.description}</span>
)}
{renderParamInput(name, schema)}
</div>
))}
{Object.keys(selectedTool.inputSchema?.properties || {}).length === 0 && (
<p className="no-params">This tool has no parameters</p>
)}
</div>
)}
</div>
<div className="tool-actions">
<Button
variant="primary"
onClick={handleExecute}
loading={isExecuting}
loadingText="Executing..."
icon={<Play size={14} />}
>
Execute Tool
</Button>
</div>
</div>
)}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,210 @@
import { useState, useCallback, useRef } from 'react'
import type { Tool } from '../App'
interface UseMCPReturn {
isConnected: boolean
isConnecting: boolean
sessionId: string | null
tools: Tool[]
error: string | null
connect: (serverUrl: string) => Promise<void>
disconnect: () => void
callTool: (toolName: string, params: Record<string, unknown>) => Promise<{
textContent: string
htmlContent: string | null
isError: boolean
}>
}
// Parse SSE response to extract JSON data
function parseSSE(text: string): unknown {
// Method 1: Look for "data: " prefix and extract JSON
const dataIndex = text.indexOf('data: ')
if (dataIndex !== -1) {
const jsonStart = dataIndex + 6 // "data: " is 6 characters
const jsonEnd = text.indexOf('\n', jsonStart)
const jsonStr = jsonEnd === -1 ? text.slice(jsonStart) : text.slice(jsonStart, jsonEnd)
try {
return JSON.parse(jsonStr)
} catch {
// Fall through
}
}
// Method 2: Try parsing as plain JSON
try {
return JSON.parse(text)
} catch {
return null
}
}
export function useMCP(): UseMCPReturn {
const [isConnected, setIsConnected] = useState(false)
const [isConnecting, setIsConnecting] = useState(false)
const [sessionId, setSessionId] = useState<string | null>(null)
const [tools, setTools] = useState<Tool[]>([])
const [error, setError] = useState<string | null>(null)
const serverUrlRef = useRef<string>('')
const connect = useCallback(async (serverUrl: string) => {
setIsConnecting(true)
setError(null)
serverUrlRef.current = serverUrl
try {
// Initialize connection
const initResponse = await fetch(serverUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'mcp-ui-inspector', version: '1.0.0' },
},
}),
})
const newSessionId = initResponse.headers.get('mcp-session-id')
const initText = await initResponse.text()
const initResult = parseSSE(initText) as { result?: unknown; error?: { message: string } }
if (!initResult || initResult.error) {
throw new Error(initResult?.error?.message || 'Failed to initialize')
}
setSessionId(newSessionId)
// Send initialized notification
await fetch(serverUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'mcp-session-id': newSessionId || '',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'notifications/initialized',
}),
})
// List tools
const toolsResponse = await fetch(serverUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'mcp-session-id': newSessionId || '',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 2,
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)
}
setIsConnected(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Connection failed')
setIsConnected(false)
setSessionId(null)
setTools([])
} finally {
setIsConnecting(false)
}
}, [])
const disconnect = useCallback(() => {
setIsConnected(false)
setSessionId(null)
setTools([])
setError(null)
}, [])
const callTool = useCallback(async (toolName: string, params: Record<string, unknown>) => {
if (!sessionId) {
throw new Error('Not connected')
}
const response = await fetch(serverUrlRef.current, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'mcp-session-id': sessionId,
},
body: JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: { name: toolName, arguments: params },
}),
})
const text = await response.text()
const result = parseSSE(text) as {
result?: {
content: Array<{
type: string
text?: string
resource?: { text?: string }
}>
}
error?: { message: string }
}
if (result?.error) {
return {
textContent: result.error.message,
htmlContent: null,
isError: true,
}
}
const content = result?.result?.content || []
let textContent = ''
let htmlContent: string | null = null
for (const item of content) {
if (item.type === 'text' && item.text) {
textContent = item.text
} else if (item.type === 'resource' && item.resource?.text) {
htmlContent = item.resource.text
}
}
return {
textContent,
htmlContent,
isError: false,
}
}, [sessionId])
return {
isConnected,
isConnecting,
sessionId,
tools,
error,
connect,
disconnect,
callTool,
}
}

View File

@ -0,0 +1,131 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-primary: #000000;
--bg-secondary: #000000;
--bg-tertiary: #141414;
--bg-hover: #30363d;
--border-color: #30363d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--accent-blue: #0c37f6;
--accent-green: #3fb950;
--accent-red: #f85149;
--accent-orange: #d29922;
--accent-purple: #a371f7;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans",
Helvetica, Arial, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
}
#root {
min-height: 100vh;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Input styles */
input,
select,
textarea {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
padding: 8px 12px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent-blue);
}
/* Button styles */
button {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
display: inline-flex;
align-items: center;
gap: 8px;
}
button:hover {
background: var(--bg-hover);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.primary {
background: var(--accent-blue);
border-color: var(--accent-blue);
color: #fff;
}
button.primary:hover {
background: #4c9aed;
}
button.success {
background: var(--accent-green);
border-color: var(--accent-green);
color: #fff;
}
button.success:hover {
background: #2ea043;
}
/* Code/mono text */
code,
.mono {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
font-size: 13px;
}
/* Selection */
::selection {
background: var(--accent-blue);
color: white;
}

View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/mcp': {
target: 'http://localhost:3000',
changeOrigin: true,
}
}
}
})

View File

@ -0,0 +1,25 @@
{
"name": "@mcp-ui/library",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"watch": "tsc --watch"
},
"exports": {
"./server": "./server/index.ts",
"./ui": "./ui/index.ts"
},
"dependencies": {
"@mcp-ui/server": "^5.15.0",
"esbuild": "^0.27.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"typescript": "^5.6.3"
}
}

View File

@ -0,0 +1,28 @@
import * as esbuild from 'esbuild';
// Cache bundled JS per entry path
const bundleCache = new Map<string, string>();
export async function bundleComponent(entryPath: string): Promise<string> {
if (bundleCache.has(entryPath)) {
return bundleCache.get(entryPath)!;
}
const result = await esbuild.build({
entryPoints: [entryPath],
bundle: true,
write: false, // No disk I/O - keep everything in memory
format: 'iife',
target: 'es2020',
jsx: 'automatic',
loader: {
'.tsx': 'tsx',
'.ts': 'ts',
},
minify: false,
});
const bundledJS = result.outputFiles[0].text;
bundleCache.set(entryPath, bundledJS);
return bundledJS;
}

View File

@ -0,0 +1,17 @@
export function generateHtml(name: string, bundledJS: string, props: Record<string, unknown>): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${name}</title>
</head>
<body>
<div id="root"></div>
<script id="mcp-initial-data" type="application/json">${JSON.stringify(props)}</script>
<script>
${bundledJS}
</script>
</body>
</html>`;
}

View File

@ -0,0 +1,52 @@
import { createUIResource, RESOURCE_URI_META_KEY, type UIResource } from "@mcp-ui/server";
import { fileURLToPath } from 'url';
import { bundleComponent } from './bundle.ts';
import { generateHtml } from './html.ts';
type UIComponentProps = {
props?: Record<string, unknown>;
frameSize?: [string, string];
}
type UIComponent = {
meta: {
[RESOURCE_URI_META_KEY]: `ui://${string}`;
};
component: (args?: UIComponentProps) => Promise<UIResource>;
}
/**
* Create a UI component that bundles on-demand
* @param name - Display name for the component
* @param entryUrl - URL from import.meta.resolve() pointing to the component entry file
*/
export function createUI(name: string, entryUrl: string): UIComponent {
const uri: `ui://${string}` = `ui://${name}`;
// Convert file:// URL to path (handles import.meta.resolve() output)
const entryPath = entryUrl.startsWith('file://') ? fileURLToPath(entryUrl) : entryUrl;
return {
meta: {
[RESOURCE_URI_META_KEY]: uri,
},
component: async (args?: UIComponentProps): Promise<UIResource> => {
const bundledJS = await bundleComponent(entryPath);
const props = args?.props || {};
const frameSize = args?.frameSize || ['700px', '600px'];
const htmlContent = generateHtml(name, bundledJS, props);
return createUIResource({
uri,
encoding: 'text',
content: {
type: 'rawHtml',
htmlString: htmlContent,
},
uiMetadata: {
'preferred-frame-size': frameSize,
'initial-render-data': props,
},
});
},
};
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["server", "ui"]
}

View File

@ -0,0 +1,43 @@
/**
* Send a prompt to the parent window
*/
export function sendPrompt(message: string) {
window.parent.postMessage({
type: 'message',
payload: {
message: message
}
}, '*');
}
/**
* Call a tool
*/
export function callTool(toolName: string, params: Record<string, unknown> = {}) {
window.parent.postMessage({
type: 'tool',
payload: {
toolName: toolName,
params: params
}
}, '*');
}
/**
* Use MCP props
*/
export function useProps<T>(defaults: T): T {
// In a real implementation, this would read from:
// window.__MCP_INITIAL_DATA__ or similar injection point
const scriptTag = document.getElementById('mcp-initial-data');
if (scriptTag?.textContent) {
try {
console.log('scriptTag.textContent', scriptTag.textContent);
return { ...defaults, ...JSON.parse(scriptTag.textContent) };
} catch {
return defaults;
}
}
return defaults;
}

View File

@ -0,0 +1,397 @@
import React, { useState, useEffect } from 'react';
import { generateMockStockData } from './stock-utils';
import { callTool, sendPrompt, useProps } from '../../library/ui';
// Types for props passed from the tool handler
interface StockData {
symbol: string;
name: string;
price: number;
change: number;
changePercent: number;
volume: string;
marketCap: string;
dayHigh: number;
dayLow: number;
history: Array<{ date: string; price: number }>;
}
interface PortfolioProps {
symbols: string[];
timeframe: '1D' | '1W' | '1M' | '3M' | '1Y';
}
export function StockDashboard() {
// These props come from the tool's inputSchema parameters
const props = useProps<PortfolioProps>({
symbols: ['AAPL', 'GOOGL', 'MSFT'],
timeframe: '1M',
});
const stocks: StockData[] = generateMockStockData(props.symbols);
const [selectedStock, setSelectedStock] = useState<string>(stocks[0]?.symbol || '');
// Reset selection when symbols change (e.g., after refresh with new params)
useEffect(() => {
setSelectedStock(stocks[0]?.symbol || '');
}, [props.symbols.join(',')]);
const selected = stocks.find(s => s.symbol === selectedStock) || stocks[0];
const totalValue = stocks.reduce((sum, s) => sum + s.price * 100, 0); // Assuming 100 shares each
const totalChange = stocks.reduce((sum, s) => sum + s.change * 100, 0);
const sendAnalysis = () => {
const analysis = `Portfolio Analysis Request:
Symbols: ${props.symbols.join(', ')}
Timeframe: ${props.timeframe}
Current Holdings:
${stocks.map(s => `- ${s.symbol} (${s.name}): $${s.price.toFixed(2)} (${s.change >= 0 ? '+' : ''}${s.changePercent.toFixed(2)}%)`).join('\n')}
Total Portfolio Value: $${totalValue.toLocaleString()}
Today's Change: ${totalChange >= 0 ? '+' : ''}$${totalChange.toFixed(2)}
Please analyze this portfolio and provide recommendations.`;
sendPrompt(analysis);
};
const requestRefresh = () => {
// This would call a tool to refresh the data
callTool('stock_portfolio');
};
return (
<>
<style>{styles}</style>
<div className="dashboard">
{/* Header */}
<div className="header">
<div>
<h1>📈 Stock Portfolio!!</h1>
<p className="subtitle">Timeframe: {props.timeframe} {stocks.length} stocks</p>
</div>
<div className="portfolio-summary">
<div className="total-value">${totalValue.toLocaleString()}</div>
<div className={`total-change ${totalChange >= 0 ? 'positive' : 'negative'}`}>
{totalChange >= 0 ? '▲' : '▼'} ${Math.abs(totalChange).toFixed(2)} today
</div>
</div>
</div>
{/* Stock Selector Tabs */}
<div className="stock-tabs">
{stocks.map(stock => (
<button
key={stock.symbol}
className={`stock-tab ${selectedStock === stock.symbol ? 'active' : ''}`}
onClick={() => setSelectedStock(stock.symbol)}
>
<span className="tab-symbol">{stock.symbol}</span>
<span className={`tab-change ${stock.change >= 0 ? 'positive' : 'negative'}`}>
{stock.change >= 0 ? '+' : ''}{stock.changePercent.toFixed(2)}%
</span>
</button>
))}
</div>
{/* Selected Stock Detail */}
{selected && (
<div className="stock-detail">
<div className="detail-header">
<div>
<h2>{selected.symbol}</h2>
<p className="company-name">{selected.name}</p>
</div>
<div className="price-section">
<div className="current-price">${selected.price.toFixed(2)}</div>
<div className={`price-change ${selected.change >= 0 ? 'positive' : 'negative'}`}>
{selected.change >= 0 ? '+' : ''}{selected.change.toFixed(2)} ({selected.changePercent.toFixed(2)}%)
</div>
</div>
</div>
<div className="chart-section">
<Sparkline
data={selected.history.map(h => h.price)}
color={selected.change >= 0 ? '#10b981' : '#ef4444'}
/>
</div>
<div className="stats-grid">
<div className="stat">
<span className="stat-label">Day High</span>
<span className="stat-value">${selected.dayHigh.toFixed(2)}</span>
</div>
<div className="stat">
<span className="stat-label">Day Low</span>
<span className="stat-value">${selected.dayLow.toFixed(2)}</span>
</div>
<div className="stat">
<span className="stat-label">Volume</span>
<span className="stat-value">{selected.volume}</span>
</div>
<div className="stat">
<span className="stat-label">Market Cap</span>
<span className="stat-value">{selected.marketCap}</span>
</div>
</div>
</div>
)}
{/* Actions */}
<div className="actions">
<button className="btn-secondary" onClick={requestRefresh}>
🔄 Refresh Data
</button>
<button className="btn-primary" onClick={sendAnalysis}>
💬 Request Analysis
</button>
</div>
</div>
</>
);
}
// Simple sparkline chart component
function Sparkline({ data, color }: { data: number[]; color: string }) {
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const height = 40;
const width = 120;
const points = data.map((value, i) => {
const x = (i / (data.length - 1)) * width;
const y = height - ((value - min) / range) * height;
return `${x},${y}`;
}).join(' ');
return (
<svg width={width} height={height} style={{ overflow: 'visible' }}>
<polyline
fill="none"
stroke={color}
strokeWidth="2"
points={points}
/>
</svg>
);
}
const styles = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
}
.dashboard {
max-width: 700px;
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #334155;
}
h1 {
font-size: 24px;
font-weight: 600;
color: #f8fafc;
}
.subtitle {
color: #64748b;
font-size: 14px;
margin-top: 4px;
}
.portfolio-summary {
text-align: right;
}
.total-value {
font-size: 28px;
font-weight: 700;
color: #f8fafc;
}
.total-change {
font-size: 14px;
margin-top: 4px;
}
.positive { color: #10b981; }
.negative { color: #ef4444; }
.stock-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
overflow-x: auto;
padding-bottom: 8px;
}
.stock-tab {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 20px;
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
min-width: 100px;
}
.stock-tab:hover {
background: #334155;
}
.stock-tab.active {
background: #3b82f6;
border-color: #3b82f6;
}
.tab-symbol {
font-weight: 600;
font-size: 16px;
color: #f8fafc;
}
.tab-change {
font-size: 12px;
margin-top: 4px;
}
.stock-detail {
background: #1e293b;
border-radius: 16px;
padding: 24px;
margin-bottom: 20px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
h2 {
font-size: 32px;
font-weight: 700;
color: #f8fafc;
}
.company-name {
color: #64748b;
font-size: 14px;
margin-top: 4px;
}
.price-section {
text-align: right;
}
.current-price {
font-size: 32px;
font-weight: 700;
color: #f8fafc;
}
.price-change {
font-size: 16px;
margin-top: 4px;
}
.chart-section {
background: #0f172a;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
display: flex;
justify-content: center;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.stat {
background: #0f172a;
padding: 16px;
border-radius: 12px;
text-align: center;
}
.stat-label {
display: block;
font-size: 12px;
color: #64748b;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: #f8fafc;
}
.actions {
display: flex;
gap: 12px;
}
button {
flex: 1;
padding: 14px 20px;
border-radius: 12px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.btn-primary {
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4);
}
.btn-secondary {
background: #334155;
color: #e2e8f0;
}
.btn-secondary:hover {
background: #475569;
}
`;

View File

@ -0,0 +1,126 @@
import React from 'react';
import { styles } from './styles';
interface WeatherData {
temp: number;
feelsLike: number;
condition: string;
icon: string;
wind: number;
humidity: number;
visibility: number;
forecast: Array<{
day: string;
icon: string;
temp: number;
}>;
}
const weatherDataByCity: WeatherData = {
temp: -2,
feelsLike: -5,
condition: 'Partly Cloudy',
icon: '⛅',
wind: 12,
humidity: 65,
visibility: 10,
forecast: [
{ day: 'TUE', icon: '🌤️', temp: -1 },
{ day: 'WED', icon: '☁️', temp: 0 },
{ day: 'THU', icon: '🌨️', temp: -3 },
{ day: 'FRI', icon: '❄️', temp: -4 },
{ day: 'SAT', icon: '⛅', temp: -2 }
]
};
export function WeatherDashboard() {
const data = weatherDataByCity;
const currentDate = new Date().toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
const sendToChat = () => {
const message = `Current weather in Oslo:
Temperature: ${data.temp}°C (feels like ${data.feelsLike}°C)
Condition: ${data.condition}
Wind: ${data.wind} km/h
Humidity: ${data.humidity}%
Visibility: ${data.visibility} km
5-Day Forecast:
${data.forecast.map(day => `${day.day}: ${day.icon} ${day.temp}°C`).join('\n')}`;
sendPrompt(message);
};
return (
<>
<style>{styles}</style>
<div className="container">
<div className="card">
<div className="header">
<h1>
<span>{data.icon}</span>
<span>Oslo</span>
</h1>
<p className="location">{currentDate}</p>
</div>
<div className="current-weather">
<div className="temp">{data.temp}°C</div>
<div className="condition">{data.condition}</div>
<div className="feels-like">Feels like {data.feelsLike}°C</div>
</div>
<div className="stats-grid">
<div className="stat-item">
<div className="stat-icon">💨</div>
<div className="stat-label">Wind</div>
<div className="stat-value">{data.wind} km/h</div>
</div>
<div className="stat-item">
<div className="stat-icon">💧</div>
<div className="stat-label">Humidity</div>
<div className="stat-value">{data.humidity}%</div>
</div>
<div className="stat-item">
<div className="stat-icon">👁</div>
<div className="stat-label">Visibility</div>
<div className="stat-value">{data.visibility} km</div>
</div>
</div>
</div>
<div className="card">
<div className="forecast">
<div className="forecast-title">5-Day Forecast</div>
<div className="forecast-grid">
{data.forecast.map((f, idx) => (
<div key={idx} className="forecast-item">
<div className="forecast-day">{f.day}</div>
<div className="forecast-icon">{f.icon}</div>
<div className="forecast-temp">{f.temp}°C</div>
</div>
))}
</div>
</div>
<div className="actions">
<button onClick={sendToChat}>
💬 Send to Chat
</button>
</div>
</div>
</div>
</>
);
}
function sendPrompt(message: string) {
throw new Error('Function not implemented.');
}

View File

@ -0,0 +1,10 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { WeatherDashboard } from './WeatherDashboard';
// This is the entry point that will be bundled
// The 'root' element is provided by the remoteDom host
const rootElement = document.getElementById('root') || document.body;
const root = createRoot(rootElement);
root.render(<WeatherDashboard />);

View File

@ -0,0 +1,10 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { StockDashboard } from './StockDashboard';
const rootElement = document.getElementById('root') || document.body;
const root = createRoot(rootElement);
root.render(<StockDashboard />);

View File

@ -0,0 +1,34 @@
// Mock data generator (would be real API calls in production)
export function generateMockStockData(symbols: string[]) {
const stockInfo: Record<string, { name: string; basePrice: number }> = {
'AAPL': { name: 'Apple Inc.', basePrice: 178 },
'GOOGL': { name: 'Alphabet Inc.', basePrice: 141 },
'MSFT': { name: 'Microsoft Corp.', basePrice: 378 },
'AMZN': { name: 'Amazon.com Inc.', basePrice: 178 },
'NVDA': { name: 'NVIDIA Corp.', basePrice: 875 },
'META': { name: 'Meta Platforms Inc.', basePrice: 505 },
'TSLA': { name: 'Tesla Inc.', basePrice: 248 },
};
return symbols.map(symbol => {
const info = stockInfo[symbol] || { name: `${symbol} Corp.`, basePrice: 100 };
const change = (Math.random() - 0.5) * 10;
const price = info.basePrice + change;
return {
symbol,
name: info.name,
price: Math.round(price * 100) / 100,
change: Math.round(change * 100) / 100,
changePercent: Math.round((change / info.basePrice) * 10000) / 100,
volume: `${Math.floor(Math.random() * 50 + 10)}M`,
marketCap: `${Math.floor(Math.random() * 3 + 1)}.${Math.floor(Math.random() * 100)}T`,
dayHigh: Math.round((price + Math.random() * 5) * 100) / 100,
dayLow: Math.round((price - Math.random() * 5) * 100) / 100,
history: Array.from({ length: 10 }, (_, i) => ({
date: `Day ${i + 1}`,
price: Math.round((info.basePrice + (Math.random() - 0.5) * 20) * 100) / 100,
})),
};
});
}

View File

@ -0,0 +1,161 @@
export const styles = `
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 16px;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
}
.card {
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
margin-bottom: 16px;
}
.header {
text-align: center;
margin-bottom: 24px;
}
h1 {
margin: 0 0 8px 0;
color: #667eea;
font-size: 32px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.location {
color: #64748b;
font-size: 18px;
margin: 0;
}
.current-weather {
text-align: center;
padding: 32px 0;
border-bottom: 2px solid #f1f5f9;
}
.temp {
font-size: 72px;
font-weight: 700;
color: #1e293b;
margin: 0;
}
.condition {
font-size: 24px;
color: #64748b;
margin: 8px 0;
}
.feels-like {
font-size: 14px;
color: #94a3b8;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-top: 24px;
}
.stat-item {
text-align: center;
padding: 16px;
background: #f8fafc;
border-radius: 12px;
}
.stat-icon {
font-size: 24px;
margin-bottom: 8px;
}
.stat-label {
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 20px;
font-weight: 600;
color: #1e293b;
margin-top: 4px;
}
.forecast {
margin-top: 16px;
}
.forecast-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
.forecast-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.forecast-item {
background: #f8fafc;
border-radius: 12px;
padding: 16px 8px;
text-align: center;
}
.forecast-day {
font-size: 12px;
color: #64748b;
font-weight: 500;
margin-bottom: 8px;
}
.forecast-icon {
font-size: 32px;
margin: 8px 0;
}
.forecast-temp {
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.actions {
margin-top: 16px;
display: flex;
gap: 12px;
}
button {
flex: 1;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
button:active { transform: translateY(0); }
.city-selector {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
}
.city-btn {
padding: 8px 16px;
font-size: 12px;
background: #f1f5f9;
color: #475569;
}
.city-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
`;

163
packages/server/index.ts Normal file
View File

@ -0,0 +1,163 @@
import express from 'express';
import cors from 'cors';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { randomUUID } from 'crypto';
import { z } from 'zod';
import { createUI } from '@mcp-ui/library/server';
const app = express();
const port = 3000;
app.use(cors({
origin: '*',
exposedHeaders: ['Mcp-Session-Id'],
allowedHeaders: ['Content-Type', 'mcp-session-id'],
}));
app.use(express.json());
// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
// Handle POST requests for client-to-server communication.
app.post('/mcp', async (req, res) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId];
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
transports[sid] = transport;
console.log(`MCP Session initialized: ${sid}`);
},
});
transport.onclose = () => {
if (transport.sessionId) {
console.log(`MCP Session closed: ${transport.sessionId}`);
delete transports[transport.sessionId];
}
};
const server = new McpServer({
name: "mcp-ui-demo-server",
version: "1.0.0"
});
// ============================================
// TOOL 1: Weather Dashboard (simple, no params)
// ============================================
const weatherDashboardUI = createUI('weather-dashboard', import.meta.resolve('./components/index.tsx'));
server.registerTool(
'weather_dashboard',
{ description: 'Interactive weather dashboard showing current conditions and 5-day forecast for Oslo' },
async () => ({
content: [
{
type: 'text' as const,
text: 'Weather Dashboard loaded. Current temperature in Oslo: -2°C, Partly Cloudy.'
},
await weatherDashboardUI.component()
],
})
);
// ============================================
// TOOL 2: Stock Portfolio (complex with inputSchema)
// ============================================
const stockDashboardUI = createUI('stock-dashboard', import.meta.resolve('./components/stock-entry.tsx'));
server.registerTool(
'stock_portfolio',
{
description: 'Interactive stock portfolio dashboard. View real-time prices, charts, and analysis for your selected stocks.',
inputSchema: {
symbols: z.array(z.string()).default(['AAPL', 'GOOGL', 'MSFT']),
timeframe: z.enum(['1D', '1W', '1M', '3M', '1Y']).default('1M'),
},
} as any,
async (params: { symbols: string[]; timeframe: '1D' | '1W' | '1M' | '3M' | '1Y' }) => {
const { symbols, timeframe } = params;
const textSummary = `Stock Portfolio Dashboard loaded.
Tracking ${symbols.length} stocks: ${symbols.join(', ')}
Timeframe: ${timeframe}
This dashboard shows real-time prices, daily changes, and sparkline charts.
Use the interactive UI to view details and request AI analysis.`;
return {
content: [
{ type: 'text' as const, text: textSummary },
await stockDashboardUI.component({ props: { symbols, timeframe }, frameSize: ['700px', '600px'] })
],
};
}
);
// ============================================
// TOOL 3: Regular data-only tool (no UI)
// ============================================
server.registerTool(
'get_stock_price',
{
description: 'Get the current price of a single stock. Returns just the data, no UI.',
inputSchema: { symbol: z.string() },
} as any,
async (params: { symbol: string }) => {
const { symbol } = params;
const price = Math.round((100 + Math.random() * 200) * 100) / 100;
const change = Math.round((Math.random() - 0.5) * 10 * 100) / 100;
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
symbol,
price,
change,
changePercent: Math.round((change / price) * 10000) / 100,
timestamp: new Date().toISOString(),
}, null, 2)
}],
};
}
);
await server.connect(transport);
} else {
return res.status(400).json({
error: { message: 'Bad Request: No valid session ID provided' },
});
}
await transport.handleRequest(req, res, req.body);
});
const handleSessionRequest = async (req: express.Request, res: express.Response) => {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
return res.status(404).send('Session not found');
}
const transport = transports[sessionId];
await transport.handleRequest(req, res);
};
app.get('/mcp', handleSessionRequest);
app.delete('/mcp', handleSessionRequest);
app.listen(port, () => {
console.log(`\n🚀 MCP UI Demo Server`);
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
console.log(`📡 MCP endpoint: http://localhost:${port}/mcp`);
console.log(`\n📦 Available tools:`);
console.log(` • weather_dashboard - Simple UI, no params`);
console.log(` • stock_portfolio - Complex UI with inputSchema params`);
console.log(` • get_stock_price - Data-only, no UI`);
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
});

View File

@ -0,0 +1,25 @@
{
"name": "@mcp-ui/demo-server",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "npx ts-node index.ts",
"dev": "npx ts-node index.ts",
"watch": "npx nodemon --exec 'npx ts-node index.ts'"
},
"dependencies": {
"@mcp-ui/library": "*",
"@modelcontextprotocol/sdk": "^1.23.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.5",
"nodemon": "^3.1.0",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
}
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
},
"include": ["."]
}