first commit
This commit is contained in:
commit
b2f633699f
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
162
README.md
Normal file
162
README.md
Normal 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
BIN
nanobot.db
Normal file
Binary file not shown.
9
nanobot.yaml
Normal file
9
nanobot.yaml
Normal 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
4101
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
packages/inspector/index.html
Normal file
13
packages/inspector/index.html
Normal 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>
|
||||
24
packages/inspector/package.json
Normal file
24
packages/inspector/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
packages/inspector/public/vite.svg
Normal file
5
packages/inspector/public/vite.svg
Normal 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 |
99
packages/inspector/src/App.css
Normal file
99
packages/inspector/src/App.css
Normal 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;
|
||||
}
|
||||
}
|
||||
138
packages/inspector/src/App.tsx
Normal file
138
packages/inspector/src/App.tsx
Normal 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
|
||||
71
packages/inspector/src/components/Button.css
Normal file
71
packages/inspector/src/components/Button.css
Normal 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);
|
||||
}
|
||||
}
|
||||
50
packages/inspector/src/components/Button.tsx
Normal file
50
packages/inspector/src/components/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
184
packages/inspector/src/components/ResultsPane.css
Normal file
184
packages/inspector/src/components/ResultsPane.css
Normal 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;
|
||||
}
|
||||
}
|
||||
99
packages/inspector/src/components/ResultsPane.tsx
Normal file
99
packages/inspector/src/components/ResultsPane.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
packages/inspector/src/components/Sidebar.css
Normal file
125
packages/inspector/src/components/Sidebar.css
Normal 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;
|
||||
}
|
||||
88
packages/inspector/src/components/Sidebar.tsx
Normal file
88
packages/inspector/src/components/Sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
252
packages/inspector/src/components/ToolsPanel.css
Normal file
252
packages/inspector/src/components/ToolsPanel.css
Normal 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);
|
||||
}
|
||||
}
|
||||
234
packages/inspector/src/components/ToolsPanel.tsx
Normal file
234
packages/inspector/src/components/ToolsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
210
packages/inspector/src/hooks/useMCP.ts
Normal file
210
packages/inspector/src/hooks/useMCP.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
131
packages/inspector/src/index.css
Normal file
131
packages/inspector/src/index.css
Normal 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;
|
||||
}
|
||||
10
packages/inspector/src/main.tsx
Normal file
10
packages/inspector/src/main.tsx
Normal 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>,
|
||||
)
|
||||
20
packages/inspector/tsconfig.json
Normal file
20
packages/inspector/tsconfig.json
Normal 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"]
|
||||
}
|
||||
15
packages/inspector/vite.config.ts
Normal file
15
packages/inspector/vite.config.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
25
packages/library/package.json
Normal file
25
packages/library/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
28
packages/library/server/bundle.ts
Normal file
28
packages/library/server/bundle.ts
Normal 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;
|
||||
}
|
||||
17
packages/library/server/html.ts
Normal file
17
packages/library/server/html.ts
Normal 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>`;
|
||||
}
|
||||
52
packages/library/server/index.ts
Normal file
52
packages/library/server/index.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
19
packages/library/tsconfig.json
Normal file
19
packages/library/tsconfig.json
Normal 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"]
|
||||
}
|
||||
43
packages/library/ui/index.ts
Normal file
43
packages/library/ui/index.ts
Normal 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;
|
||||
}
|
||||
397
packages/server/components/StockDashboard.tsx
Normal file
397
packages/server/components/StockDashboard.tsx
Normal 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;
|
||||
}
|
||||
`;
|
||||
|
||||
126
packages/server/components/WeatherDashboard.tsx
Normal file
126
packages/server/components/WeatherDashboard.tsx
Normal 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.');
|
||||
}
|
||||
|
||||
10
packages/server/components/index.tsx
Normal file
10
packages/server/components/index.tsx
Normal 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 />);
|
||||
|
||||
10
packages/server/components/stock-entry.tsx
Normal file
10
packages/server/components/stock-entry.tsx
Normal 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 />);
|
||||
|
||||
|
||||
|
||||
34
packages/server/components/stock-utils.ts
Normal file
34
packages/server/components/stock-utils.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
161
packages/server/components/styles.ts
Normal file
161
packages/server/components/styles.ts
Normal 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
163
packages/server/index.ts
Normal 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`);
|
||||
});
|
||||
25
packages/server/package.json
Normal file
25
packages/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
packages/server/tsconfig.json
Normal file
20
packages/server/tsconfig.json
Normal 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": ["."]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user