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