# Directory Structure ``` ├── .gitignore ├── components.json ├── docker-build-push.sh ├── Dockerfile ├── eslint.config.js ├── index.html ├── LICENSE ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public │ └── vite.svg ├── README.md ├── server.ts ├── src │ ├── App.css │ ├── App.tsx │ ├── assets │ │ └── react.svg │ ├── components │ │ ├── ui │ │ │ ├── alert.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── input.tsx │ │ │ └── select.tsx │ │ └── WebcamCapture.tsx │ ├── index.css │ ├── lib │ │ └── utils.ts │ ├── main.tsx │ ├── utils │ │ └── screenCapture.ts │ └── vite-env.d.ts ├── tailwind.config.js ├── transport │ ├── base-transport.ts │ ├── stdio-transport.ts │ ├── streamable-http-transport.ts │ └── transport-factory.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.server.json ├── vite.config.ts └── webcam-server-factory.ts ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # ⭐⭐ mcp-webcam 0.2.0 - the 50 Star Update ⭐⭐ 2 | 3 | In celebration of getting 52 GitHub stars, `mcp-webcam 0.2.0` is here! Now supports streamable-http!! No installation required! - try it now at [`https://webcam.fast-agent.ai/`](https://webcam.fast-agent.ai/). You can specify your own UserID by adding `?user=<YOUR_USER_ID>` after the URL. Note this shared instance is for fun, not security - see below for instructions how to run your own copy locally. 4 | 5 | In streamable-http mode multiple clients can connect simultaneously, and you can choose which is used for Sampling. 6 | 7 |  8 | 9 | If we get to 100 stars I'll add another feature 😊. 10 | 11 | ## Multi-user Mode 12 | 13 | When run in Streaming mode, if you set an MCP_HOST environment variable the host name is used as a prefix in URL construction, and 5 character UserIDs are automatically generated when the User lands on the webpage. 14 | 15 |  16 | 17 | 18 | ## mcp-webcam 19 | 20 | MCP Server that provides access to your WebCam. Provides `capture` and `screenshot` tools to take an image from the Webcam, or take a screenshot. The current image is also available as a Resource. 21 | 22 | ### MCP Sampling 23 | 24 | `mcp-webcam` supports "sampling"! Press the "Sample" button to send a sampling request to the Client along with your entered message. 25 | 26 | > [!TIP] 27 | > Claude Desktop does not currently support Sampling. If you want a Client that can handle multi-modal sampling request, try https://github.com/evalstate/fast-agent/ or VSCode (more details below). 28 | 29 | ## Installation and Running 30 | 31 | ### NPX 32 | 33 | Install a recent version of [NodeJS](https://nodejs.org/en/download) for your platform. The NPM package is `@llmindset/mcp-webcam`. 34 | 35 | To start in **STDIO** mode: `npx @llmindset/mcp-webcam`. This starts the `mcp-webcam` UI on port 3333. Point your browser at `http://localhost:3333` to get started. 36 | 37 | To change the port: `npx @llmindset/mcp-webcam 9999`. This starts `mcp-webcam` the UI on port 9999. 38 | 39 | For **Streaming HTTP** mode: `npx @llmindset/mcp-webcam --streaming`. This will make the UI available at `http://localhost:3333` and the MCP Server available at `http://localhost:3333/mcp`. 40 | 41 | ### Docker 42 | 43 | You can run `mcp-webcam` using Docker. By default, it starts in **streaming mode**: 44 | 45 | ```bash 46 | docker run -p 3333:3333 ghcr.io/evalstate/mcp-webcam:latest 47 | ``` 48 | 49 | #### Environment Variables 50 | 51 | - `MCP_TRANSPORT_MODE` - Set to `stdio` for STDIO mode, defaults to `streaming` 52 | - `PORT` - The port to run on (default: `3333`) 53 | - `BIND_HOST` - Network interface to bind the server to (default: `localhost`) 54 | - `MCP_HOST` - Public-facing URL for user instructions and MCP client connections (default: `http://localhost:3333`) 55 | 56 | #### Examples 57 | 58 | ```bash 59 | # STDIO mode 60 | docker run -p 3333:3333 -e MCP_TRANSPORT_MODE=stdio ghcr.io/evalstate/mcp-webcam:latest 61 | 62 | # Custom port 63 | docker run -p 8080:8080 -e PORT=8080 ghcr.io/evalstate/mcp-webcam:latest 64 | 65 | # For cloud deployments with custom domain (e.g., Hugging Face Spaces) 66 | docker run -p 3333:3333 -e MCP_HOST=https://evalstate-mcp-webcam.hf.space ghcr.io/evalstate/mcp-webcam:latest 67 | 68 | # Complete cloud deployment example 69 | docker run -p 3333:3333 -e MCP_HOST=https://your-domain.com ghcr.io/evalstate/mcp-webcam:latest 70 | ``` 71 | 72 | ## Clients 73 | 74 | If you want a Client that supports sampling try: 75 | 76 | ### fast-agent 77 | 78 | Start the `mcp-webcam` in streaming mode, install [`uv`](https://docs.astral.sh/uv/) and connect with: 79 | 80 | `uvx fast-agent-mcp go --url http://localhost:3333/mcp` 81 | 82 | `fast-agent` currently uses Haiku as its default model, so set an `ANTHROPIC_API_KEY`. If you want to use a different model, you can add `--model` on the command line. More instructions for installation and configuration are available here: https://fast-agent.ai/models/. 83 | 84 | To start the server in STDIO mode, add the following to your `fastagent.config.yaml` 85 | 86 | ```yaml 87 | webcam_local: 88 | command: "npx" 89 | args: ["@llmindset/mcp-webcam"] 90 | ``` 91 | 92 | ### VSCode 93 | 94 | VSCode versions 1.101.0 and above support MCP Sampling. Simply start `mcp-webcam` in streaming mode, and add `http://localhost:3333/mcp` as an MCP Server to get started. 95 | 96 | ### Claude Desktop 97 | 98 | Claude Desktop does **NOT** support Sampling. To run `mcp-webcam` from Claude Desktop, add the following to the `mcpServers` section of your `claude_desktop_config.json` file: 99 | 100 | ```json 101 | "webcam": { 102 | "command": "npx", 103 | "args": [ 104 | "-y", 105 | "@llmindset/mcp-webcam" 106 | ] 107 | } 108 | ``` 109 | 110 | Start Claude Desktop, and connect to `http://localhost:3333`. You can then ask Claude to `get the latest picture from my webcam`, or `Claude, take a look at what I'm holding` or `what colour top am i wearing?`. You can "freeze" the current image and that will be returned to Claude rather than a live capture. 111 | 112 | You can ask for Screenshots - navigate to the browser so that you can guide the capture area when the request comes in. Screenshots are automatically resized to be manageable for Claude (useful if you have a 4K Screen). The button is there to allow testing of your platform specific Screenshot UX - it doesn't do anything other than prepare you for a Claude intiated request. NB this does not **not** work on Safari as it requires human initiation. 113 | 114 | ## Other notes 115 | 116 | That's it really. 117 | 118 | This MCP Server was built to demonstrate exposing a User Interface on an MCP Server, and serving live resources back to Claude Desktop. 119 | 120 | This project might prove useful if you want to build a local, interactive MCP Server. 121 | 122 | Thanks to https://github.com/tadasant for help with testing and setup. 123 | 124 | Please read the article at [https://llmindset.co.uk/posts/2025/01/resouce-handling-mcp](https://llmindset.co.uk/posts/2025/01/mcp-files-resources-part1/) for more details about handling files and resources in LLM / MCP Chat Applications, and why you might want to do this. 125 | ``` -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- ```typescript 1 | /// <reference types="vite/client" /> 2 | ``` -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- ```javascript 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | ``` -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | ``` -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | <StrictMode> 8 | <App /> 9 | </StrictMode>, 10 | ) 11 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | 3 | "files": [], 4 | "references": [ 5 | { "path": "./tsconfig.app.json" }, 6 | { "path": "./tsconfig.node.json" }, 7 | { "path": "./tsconfig.server.json" } 8 | ], 9 | "compilerOptions": { 10 | "baseUrl": ".", 11 | "paths": { 12 | "@/*": ["./src/*"] 13 | } 14 | } 15 | } 16 | ``` -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- ```typescript 1 | import path from "path" 2 | import { defineConfig } from 'vite' 3 | import react from '@vitejs/plugin-react' 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | }) 14 | ``` -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "esModuleInterop": true, 7 | "outDir": "dist", 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "isolatedModules": true 11 | }, 12 | "include": ["server.ts"] 13 | } ``` -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import "./App.css"; 2 | import { WebcamCapture } from "./components/WebcamCapture"; 3 | 4 | function App() { 5 | return ( 6 | <div className="min-h-screen bg-background"> 7 | <main className="py-2 sm:py-4"> 8 | <div className="container mx-auto px-2 sm:px-4"> 9 | <WebcamCapture /> 10 | </div> 11 | </main> 12 | </div> 13 | ); 14 | } 15 | 16 | export default App; 17 | ``` -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- ```html 1 | <!doctype html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8" /> 5 | <link rel="icon" type="image/svg+xml" href="/vite.svg" /> 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 | <title>mcp-webcam</title> 8 | </head> 9 | <body> 10 | <div id="root"></div> 11 | <script type="module" src="/src/main.tsx"></script> 12 | </body> 13 | </html> 14 | ``` -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/index.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } ``` -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- ```css 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | ``` -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "NodeNext", 7 | "moduleResolution": "NodeNext", 8 | "skipLibCheck": true, 9 | "composite": true, 10 | "allowSyntheticDefaultImports": true, 11 | "esModuleInterop": true, 12 | /* Bundler mode */ 13 | "allowImportingTsExtensions": true, 14 | "isolatedModules": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["vite.config.ts"] 26 | } 27 | ``` -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "@/*": ["./src/*"] 27 | } 28 | }, 29 | "include": ["src"] 30 | } 31 | ``` -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | <input 9 | type={type} 10 | className={cn( 11 | "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 12 | className 13 | )} 14 | ref={ref} 15 | {...props} 16 | /> 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | ``` -------------------------------------------------------------------------------- /docker-build-push.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | # Bash script for building and pushing Docker image 3 | 4 | GITHUB_USERNAME="evalstate" 5 | IMAGE_NAME="mcp-webcam" 6 | VERSION=$(date +"%Y%m%d-%H%M%S") 7 | 8 | echo "Building mcp-webcam Docker image..." 9 | 10 | # Build the Docker image 11 | docker build -t ghcr.io/${GITHUB_USERNAME}/${IMAGE_NAME}:latest . 12 | docker tag ghcr.io/${GITHUB_USERNAME}/${IMAGE_NAME}:latest ghcr.io/${GITHUB_USERNAME}/${IMAGE_NAME}:${VERSION} 13 | 14 | echo "Pushing to GitHub Container Registry..." 15 | 16 | # Push the image to GitHub Container Registry 17 | docker push ghcr.io/${GITHUB_USERNAME}/${IMAGE_NAME}:latest 18 | docker push ghcr.io/${GITHUB_USERNAME}/${IMAGE_NAME}:${VERSION} 19 | 20 | echo "Build and push completed successfully" 21 | echo "Image: ghcr.io/${GITHUB_USERNAME}/${IMAGE_NAME}:${VERSION}" 22 | echo "" 23 | echo "To run the container:" 24 | echo "docker run -p 3333:3333 ghcr.io/${GITHUB_USERNAME}/${IMAGE_NAME}:latest" ``` -------------------------------------------------------------------------------- /transport/transport-factory.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { BaseTransport } from './base-transport.js'; 2 | import { StdioTransport } from './stdio-transport.js'; 3 | import { StreamableHttpTransport } from './streamable-http-transport.js'; 4 | import type { ServerFactory } from './base-transport.js'; 5 | import type { Express } from 'express'; 6 | 7 | export type TransportType = 'stdio' | 'streamable-http'; 8 | 9 | /** 10 | * Factory for creating transport instances 11 | */ 12 | export class TransportFactory { 13 | static create( 14 | type: TransportType, 15 | serverFactory: ServerFactory, 16 | app?: Express 17 | ): BaseTransport { 18 | switch (type) { 19 | case 'stdio': 20 | return new StdioTransport(serverFactory); 21 | case 'streamable-http': 22 | if (!app) { 23 | throw new Error('Express app is required for StreamableHTTP transport'); 24 | } 25 | return new StreamableHttpTransport(serverFactory, app); 26 | default: 27 | throw new Error(`Unknown transport type: ${type}`); 28 | } 29 | } 30 | } ``` -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- ```javascript 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | parserOptions: { 16 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 17 | tsconfigRootDir: import.meta.dirname, 18 | }, 19 | }, 20 | settings: { react: { version: '18.3' } }, 21 | plugins: { 22 | 'react-hooks': reactHooks, 23 | 'react-refresh': reactRefresh, 24 | react, 25 | }, 26 | rules: { 27 | ...reactHooks.configs.recommended.rules, 28 | ...react.configs.recommended.rules, 29 | ...react.configs['jsx-runtime'].rules, 30 | 'react-refresh/only-export-components': [ 31 | 'warn', 32 | { allowConstantExport: true }, 33 | ], 34 | }, 35 | }, 36 | ) 37 | ``` -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import * as React from "react" 2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 3 | import { Check } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Checkbox = React.forwardRef< 8 | React.ElementRef<typeof CheckboxPrimitive.Root>, 9 | React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> 10 | >(({ className, ...props }, ref) => ( 11 | <CheckboxPrimitive.Root 12 | ref={ref} 13 | className={cn( 14 | "peer h-4 w-4 shrink-0 rounded-sm border border-input bg-background shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary", 15 | className 16 | )} 17 | {...props} 18 | > 19 | <CheckboxPrimitive.Indicator 20 | className={cn("flex items-center justify-center text-current")} 21 | > 22 | <Check className="h-4 w-4" /> 23 | </CheckboxPrimitive.Indicator> 24 | </CheckboxPrimitive.Root> 25 | )) 26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 27 | 28 | export { Checkbox } 29 | ``` -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes<HTMLDivElement>, 28 | VariantProps<typeof badgeVariants> {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 | <div className={cn(badgeVariants({ variant }), className)} {...props} /> 33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | FROM node:22-alpine 2 | 3 | # Enable pnpm 4 | RUN corepack enable pnpm 5 | 6 | WORKDIR /app 7 | 8 | # Copy package files 9 | COPY package.json package-lock.json* pnpm-lock.yaml* ./ 10 | 11 | # Install dependencies 12 | RUN npm ci --only=production 13 | 14 | # Copy source code 15 | COPY . . 16 | 17 | # Clean any existing build artifacts 18 | RUN rm -rf ./dist && \ 19 | echo "=== Cleaned build artifacts ===" 20 | 21 | # Build the application 22 | RUN echo "=== Starting build process ===" && \ 23 | npm run build && \ 24 | echo "=== Build completed ===" 25 | 26 | # Debug: Verify build output 27 | RUN echo "=== Verifying build output ===" && \ 28 | ls -la dist/ && \ 29 | echo "=== server.js permissions ===" && \ 30 | ls -la dist/server.js && \ 31 | echo "=== Testing executable ===" && \ 32 | node dist/server.js --help 33 | 34 | # Set environment variables 35 | ENV NODE_ENV=production 36 | ENV PORT=3333 37 | ENV MCP_TRANSPORT_MODE=streaming 38 | ENV BIND_HOST=0.0.0.0 39 | 40 | # Expose port 41 | EXPOSE 3333 42 | 43 | # Use a shell script to handle conditional startup 44 | RUN echo '#!/bin/sh' > /app/start.sh && \ 45 | echo 'if [ "$MCP_TRANSPORT_MODE" = "stdio" ]; then' >> /app/start.sh && \ 46 | echo ' exec node dist/server.js --port $PORT' >> /app/start.sh && \ 47 | echo 'else' >> /app/start.sh && \ 48 | echo ' exec node dist/server.js --streaming --port $PORT' >> /app/start.sh && \ 49 | echo 'fi' >> /app/start.sh && \ 50 | chmod +x /app/start.sh 51 | 52 | # Use the start script 53 | CMD ["/app/start.sh"] ``` -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- ``` 1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg> ``` -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- ```javascript 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class"], 4 | content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"], 5 | theme: { 6 | extend: { 7 | borderRadius: { 8 | lg: 'var(--radius)', 9 | md: 'calc(var(--radius) - 2px)', 10 | sm: 'calc(var(--radius) - 4px)' 11 | }, 12 | colors: { 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | card: { 16 | DEFAULT: 'hsl(var(--card))', 17 | foreground: 'hsl(var(--card-foreground))' 18 | }, 19 | popover: { 20 | DEFAULT: 'hsl(var(--popover))', 21 | foreground: 'hsl(var(--popover-foreground))' 22 | }, 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))' 34 | }, 35 | accent: { 36 | DEFAULT: 'hsl(var(--accent))', 37 | foreground: 'hsl(var(--accent-foreground))' 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))' 42 | }, 43 | border: 'hsl(var(--border))', 44 | input: 'hsl(var(--input))', 45 | ring: 'hsl(var(--ring))', 46 | chart: { 47 | '1': 'hsl(var(--chart-1))', 48 | '2': 'hsl(var(--chart-2))', 49 | '3': 'hsl(var(--chart-3))', 50 | '4': 'hsl(var(--chart-4))', 51 | '5': 'hsl(var(--chart-5))' 52 | } 53 | } 54 | } 55 | }, 56 | plugins: [require("tailwindcss-animate")], 57 | } 58 | 59 | ``` -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> 25 | >(({ className, variant, ...props }, ref) => ( 26 | <div 27 | ref={ref} 28 | role="alert" 29 | className={cn(alertVariants({ variant }), className)} 30 | {...props} 31 | /> 32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes<HTMLHeadingElement> 38 | >(({ className, ...props }, ref) => ( 39 | <h5 40 | ref={ref} 41 | className={cn("mb-1 font-medium leading-none tracking-tight", className)} 42 | {...props} 43 | /> 44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes<HTMLParagraphElement> 50 | >(({ className, ...props }, ref) => ( 51 | <div 52 | ref={ref} 53 | className={cn("text-sm [&_p]:leading-relaxed", className)} 54 | {...props} 55 | /> 56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | ``` -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes<HTMLDivElement> 8 | >(({ className, ...props }, ref) => ( 9 | <div 10 | ref={ref} 11 | className={cn( 12 | "rounded-xl border bg-card text-card-foreground shadow", 13 | className 14 | )} 15 | {...props} 16 | /> 17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes<HTMLDivElement> 23 | >(({ className, ...props }, ref) => ( 24 | <div 25 | ref={ref} 26 | className={cn("flex flex-col space-y-1.5 p-6", className)} 27 | {...props} 28 | /> 29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes<HTMLDivElement> 35 | >(({ className, ...props }, ref) => ( 36 | <div 37 | ref={ref} 38 | className={cn("font-semibold leading-none tracking-tight", className)} 39 | {...props} 40 | /> 41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes<HTMLDivElement> 47 | >(({ className, ...props }, ref) => ( 48 | <div 49 | ref={ref} 50 | className={cn("text-sm text-muted-foreground", className)} 51 | {...props} 52 | /> 53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes<HTMLDivElement> 59 | >(({ className, ...props }, ref) => ( 60 | <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> 61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes<HTMLDivElement> 67 | >(({ className, ...props }, ref) => ( 68 | <div 69 | ref={ref} 70 | className={cn("flex items-center p-6 pt-0", className)} 71 | {...props} 72 | /> 73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | ``` -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes<HTMLButtonElement>, 39 | VariantProps<typeof buttonVariants> { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | <Comp 48 | className={cn(buttonVariants({ variant, size, className }))} 49 | ref={ref} 50 | {...props} 51 | /> 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | ``` -------------------------------------------------------------------------------- /src/utils/screenCapture.ts: -------------------------------------------------------------------------------- ```typescript 1 | export async function captureScreen(): Promise<string> { 2 | let stream: MediaStream | undefined; 3 | try { 4 | stream = await navigator.mediaDevices.getDisplayMedia({ 5 | video: true, 6 | audio: false, 7 | }); 8 | 9 | const canvas = document.createElement("canvas"); 10 | const video = document.createElement("video"); 11 | 12 | await new Promise((resolve) => { 13 | video.onloadedmetadata = () => { 14 | canvas.width = video.videoWidth; 15 | canvas.height = video.videoHeight; 16 | video.play(); 17 | resolve(null); 18 | }; 19 | if (stream) { 20 | video.srcObject = stream; 21 | } else { 22 | throw Error("No stream available"); 23 | } 24 | }); 25 | 26 | const context = canvas.getContext("2d"); 27 | context?.drawImage(video, 0, 0, canvas.width, canvas.height); 28 | 29 | // Check if resizing is needed 30 | const MAX_DIMENSION = 1568; 31 | if (canvas.width > MAX_DIMENSION || canvas.height > MAX_DIMENSION) { 32 | const scaleFactor = MAX_DIMENSION / Math.max(canvas.width, canvas.height); 33 | const newWidth = Math.round(canvas.width * scaleFactor); 34 | const newHeight = Math.round(canvas.height * scaleFactor); 35 | 36 | const resizeCanvas = document.createElement("canvas"); 37 | resizeCanvas.width = newWidth; 38 | resizeCanvas.height = newHeight; 39 | const resizeContext = resizeCanvas.getContext("2d"); 40 | resizeContext?.drawImage(canvas, 0, 0, newWidth, newHeight); 41 | return resizeCanvas.toDataURL("image/png"); 42 | } 43 | 44 | return canvas.toDataURL("image/png"); 45 | } catch (error) { 46 | console.error("Error capturing screenshot:", error); 47 | throw error; 48 | } finally { 49 | if (stream) { 50 | stream.getTracks().forEach((track) => track.stop()); 51 | } 52 | } 53 | } 54 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "@llmindset/mcp-webcam", 3 | "version": "0.2.2", 4 | "type": "module", 5 | "publishConfig": { 6 | "access": "public" 7 | }, 8 | "bin": { 9 | "mcp-webcam": "dist/server.js" 10 | }, 11 | "files": [ 12 | "dist", 13 | "README.md" 14 | ], 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/evalstate/mcp-webcam.git" 18 | }, 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/evalstate/mcp-webcam/issues" 22 | }, 23 | "homepage": "https://github.com/evalstate/mcp-webcam#readme", 24 | "scripts": { 25 | "dev": "vite", 26 | "build": "tsc -b && vite build && tsc -p tsconfig.server.json", 27 | "start": "node dist/server.js", 28 | "start:streaming": "node dist/server.js --streaming", 29 | "lint": "eslint .", 30 | "preview": "vite preview", 31 | "prepublishOnly": "npm run build", 32 | "postbuild": "node -e \"process.platform !== 'win32' && require('child_process').execSync('chmod +x dist/server.js')\"" 33 | }, 34 | "dependencies": { 35 | "@modelcontextprotocol/sdk": "^1.13.2", 36 | "@radix-ui/react-checkbox": "^1.3.2", 37 | "@radix-ui/react-dropdown-menu": "^2.1.15", 38 | "@radix-ui/react-select": "^2.1.4", 39 | "@radix-ui/react-slot": "^1.1.1", 40 | "class-variance-authority": "^0.7.1", 41 | "clsx": "^2.1.1", 42 | "express": "^4.21.2", 43 | "lucide-react": "^0.473.0", 44 | "react": "^18.3.1", 45 | "react-dom": "^18.3.1", 46 | "react-webcam": "^7.2.0", 47 | "sonner": "^1.7.2", 48 | "tailwind-merge": "^2.6.0", 49 | "tailwindcss-animate": "^1.0.7", 50 | "zod-to-json-schema": "^3.24.1" 51 | }, 52 | "devDependencies": { 53 | "@eslint/js": "^9.17.0", 54 | "@types/express": "^5.0.3", 55 | "@types/node": "^22.15.31", 56 | "@types/react": "^18.3.18", 57 | "@types/react-dom": "^18.3.5", 58 | "@vitejs/plugin-react": "^4.3.4", 59 | "autoprefixer": "^10.4.20", 60 | "eslint": "^9.18.0", 61 | "eslint-plugin-react": "^7.37.4", 62 | "eslint-plugin-react-hooks": "^5.0.0", 63 | "eslint-plugin-react-refresh": "^0.4.16", 64 | "globals": "^15.14.0", 65 | "postcss": "^8.5.1", 66 | "tailwindcss": "^3.4.17", 67 | "typescript": "~5.6.2", 68 | "typescript-eslint": "^8.18.2", 69 | "vite": "^6.0.5" 70 | }, 71 | "packageManager": "[email protected]+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" 72 | } 73 | ``` -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- ```css 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | 6 | :root { 7 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 8 | line-height: 1.5; 9 | font-weight: 400; 10 | 11 | color-scheme: light dark; 12 | color: rgba(255, 255, 255, 0.87); 13 | background-color: #242424; 14 | 15 | font-synthesis: none; 16 | text-rendering: optimizeLegibility; 17 | -webkit-font-smoothing: antialiased; 18 | -moz-osx-font-smoothing: grayscale; 19 | } 20 | 21 | a { 22 | font-weight: 500; 23 | color: #646cff; 24 | text-decoration: inherit; 25 | } 26 | a:hover { 27 | color: #535bf2; 28 | } 29 | 30 | body { 31 | margin: 0; 32 | display: flex; 33 | justify-content: center; 34 | align-items: flex-start; 35 | min-width: 320px; 36 | min-height: 100vh; 37 | } 38 | 39 | h1 { 40 | font-size: 3.2em; 41 | line-height: 1.1; 42 | } 43 | 44 | button { 45 | border-radius: 8px; 46 | border: 1px solid transparent; 47 | padding: 0.6em 1.2em; 48 | font-size: 1em; 49 | font-weight: 500; 50 | font-family: inherit; 51 | background-color: #1a1a1a; 52 | cursor: pointer; 53 | transition: border-color 0.25s; 54 | } 55 | button:hover { 56 | border-color: #646cff; 57 | } 58 | button:focus, 59 | button:focus-visible { 60 | outline: 4px auto -webkit-focus-ring-color; 61 | } 62 | 63 | @media (prefers-color-scheme: light) { 64 | :root { 65 | color: #213547; 66 | background-color: #ffffff; 67 | } 68 | a:hover { 69 | color: #747bff; 70 | } 71 | button { 72 | background-color: #f9f9f9; 73 | } 74 | } 75 | 76 | @layer base { 77 | :root { 78 | --background: 0 0% 100%; 79 | --foreground: 240 10% 3.9%; 80 | --card: 0 0% 100%; 81 | --card-foreground: 240 10% 3.9%; 82 | --popover: 0 0% 100%; 83 | --popover-foreground: 240 10% 3.9%; 84 | --primary: 240 5.9% 10%; 85 | --primary-foreground: 0 0% 98%; 86 | --secondary: 240 4.8% 95.9%; 87 | --secondary-foreground: 240 5.9% 10%; 88 | --muted: 240 4.8% 95.9%; 89 | --muted-foreground: 240 3.8% 46.1%; 90 | --accent: 240 4.8% 95.9%; 91 | --accent-foreground: 240 5.9% 10%; 92 | --destructive: 0 84.2% 60.2%; 93 | --destructive-foreground: 0 0% 98%; 94 | --border: 240 5.9% 90%; 95 | --input: 240 5.9% 90%; 96 | --ring: 240 10% 3.9%; 97 | --chart-1: 12 76% 61%; 98 | --chart-2: 173 58% 39%; 99 | --chart-3: 197 37% 24%; 100 | --chart-4: 43 74% 66%; 101 | --chart-5: 27 87% 67%; 102 | --radius: 0.5rem; 103 | } 104 | .dark { 105 | --background: 240 10% 3.9%; 106 | --foreground: 0 0% 98%; 107 | --card: 240 10% 3.9%; 108 | --card-foreground: 0 0% 98%; 109 | --popover: 240 10% 3.9%; 110 | --popover-foreground: 0 0% 98%; 111 | --primary: 0 0% 98%; 112 | --primary-foreground: 240 5.9% 10%; 113 | --secondary: 240 3.7% 15.9%; 114 | --secondary-foreground: 0 0% 98%; 115 | --muted: 240 3.7% 15.9%; 116 | --muted-foreground: 240 5% 64.9%; 117 | --accent: 240 3.7% 15.9%; 118 | --accent-foreground: 0 0% 98%; 119 | --destructive: 0 62.8% 30.6%; 120 | --destructive-foreground: 0 0% 98%; 121 | --border: 240 3.7% 15.9%; 122 | --input: 240 3.7% 15.9%; 123 | --ring: 240 4.9% 83.9%; 124 | --chart-1: 220 70% 50%; 125 | --chart-2: 160 60% 45%; 126 | --chart-3: 30 80% 55%; 127 | --chart-4: 280 65% 60%; 128 | --chart-5: 340 75% 55%; 129 | } 130 | } 131 | 132 | @layer base { 133 | * { 134 | @apply border-border; 135 | } 136 | body { 137 | @apply bg-background text-foreground; 138 | } 139 | } 140 | 141 | /* Fix checkbox styling to prevent button styles from affecting it */ 142 | button[role="checkbox"] { 143 | padding: 0; 144 | background-color: transparent; 145 | border-radius: 0.125rem; 146 | } 147 | ``` -------------------------------------------------------------------------------- /transport/stdio-transport.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { StatefulTransport, type TransportOptions, type BaseSession } from './base-transport.js'; 2 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 3 | import { Logger } from '../utils/logger.js'; 4 | 5 | type StdioSession = BaseSession<StdioServerTransport>; 6 | 7 | /** 8 | * Implementation of STDIO transport 9 | */ 10 | export class StdioTransport extends StatefulTransport<StdioSession> { 11 | private readonly SESSION_ID = 'STDIO'; 12 | 13 | async initialize(_options: TransportOptions): Promise<void> { 14 | const transport = new StdioServerTransport(); 15 | 16 | // Create server instance using factory 17 | const server = await this.serverFactory(); 18 | 19 | // Create session with metadata tracking 20 | const session: StdioSession = { 21 | transport, 22 | server, 23 | metadata: { 24 | id: this.SESSION_ID, 25 | connectedAt: new Date(), 26 | lastActivity: new Date(), 27 | capabilities: {}, 28 | }, 29 | }; 30 | 31 | // Store session in map 32 | this.sessions.set(this.SESSION_ID, session); 33 | 34 | try { 35 | // Set up request/response interceptors for activity tracking 36 | const originalSendMessage = transport.send.bind(transport); 37 | transport.send = (message) => { 38 | this.updateSessionActivity(this.SESSION_ID); 39 | return originalSendMessage(message); 40 | }; 41 | 42 | // Set up oninitialized callback to capture client info using base class helper 43 | server.server.oninitialized = this.createClientInfoCapture(this.SESSION_ID); 44 | 45 | // Set up error tracking 46 | server.server.onerror = (error) => { 47 | Logger.error('STDIO server error:', error); 48 | }; 49 | 50 | // Handle transport closure 51 | transport.onclose = () => { 52 | Logger.info('STDIO transport closed'); 53 | void this.handleShutdown('transport closed'); 54 | }; 55 | 56 | await server.connect(transport); 57 | Logger.info('STDIO transport initialized'); 58 | } catch (error) { 59 | Logger.error('Error connecting STDIO transport:', error); 60 | // Clean up on error 61 | this.sessions.delete(this.SESSION_ID); 62 | throw error; 63 | } 64 | } 65 | 66 | /** 67 | * STDIO doesn't need stale session removal since there's only one persistent session 68 | */ 69 | protected removeStaleSession(sessionId: string): Promise<void> { 70 | // STDIO has only one session and it's not subject to staleness 71 | Logger.debug(`STDIO session staleness check for ${sessionId} (no-op)`); 72 | return Promise.resolve(); 73 | } 74 | 75 | async cleanup(): Promise<void> { 76 | const session = this.sessions.get(this.SESSION_ID); 77 | if (session) { 78 | try { 79 | await session.transport.close(); 80 | } catch (error) { 81 | Logger.error('Error closing STDIO transport:', error); 82 | } 83 | try { 84 | await session.server.close(); 85 | } catch (error) { 86 | Logger.error('Error closing STDIO server:', error); 87 | } 88 | } 89 | this.sessions.clear(); 90 | Logger.info('STDIO transport cleaned up'); 91 | } 92 | 93 | /** 94 | * Get the STDIO session if it exists 95 | */ 96 | getSession(): StdioSession | undefined { 97 | return this.sessions.get(this.SESSION_ID); 98 | } 99 | 100 | /** 101 | * Handle shutdown for STDIO 102 | */ 103 | private async handleShutdown(reason: string): Promise<void> { 104 | Logger.info(`Initiating shutdown (reason: ${reason}`); 105 | 106 | try { 107 | await this.cleanup(); 108 | process.exit(0); 109 | } catch (error) { 110 | Logger.error('Error during shutdown:', error); 111 | process.exit(1); 112 | } 113 | } 114 | 115 | /** 116 | * Set up stdin/stdout event handlers 117 | */ 118 | setupStdioHandlers(): void { 119 | // Handle stdin/stdout events 120 | process.stdin.on('end', () => this.handleShutdown('stdin ended')); 121 | process.stdin.on('close', () => this.handleShutdown('stdin closed')); 122 | process.stdout.on('error', () => this.handleShutdown('stdout error')); 123 | process.stdout.on('close', () => this.handleShutdown('stdout closed')); 124 | } 125 | } ``` -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- ``` 1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg> ``` -------------------------------------------------------------------------------- /src/components/ui/select.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import * as React from "react" 2 | import * as SelectPrimitive from "@radix-ui/react-select" 3 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Select = SelectPrimitive.Root 8 | 9 | const SelectGroup = SelectPrimitive.Group 10 | 11 | const SelectValue = SelectPrimitive.Value 12 | 13 | const SelectTrigger = React.forwardRef< 14 | React.ElementRef<typeof SelectPrimitive.Trigger>, 15 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> 16 | >(({ className, children, ...props }, ref) => ( 17 | <SelectPrimitive.Trigger 18 | ref={ref} 19 | className={cn( 20 | "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", 21 | className 22 | )} 23 | {...props} 24 | > 25 | {children} 26 | <SelectPrimitive.Icon asChild> 27 | <ChevronDown className="h-4 w-4 opacity-50" /> 28 | </SelectPrimitive.Icon> 29 | </SelectPrimitive.Trigger> 30 | )) 31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 32 | 33 | const SelectScrollUpButton = React.forwardRef< 34 | React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, 35 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> 36 | >(({ className, ...props }, ref) => ( 37 | <SelectPrimitive.ScrollUpButton 38 | ref={ref} 39 | className={cn( 40 | "flex cursor-default items-center justify-center py-1", 41 | className 42 | )} 43 | {...props} 44 | > 45 | <ChevronUp className="h-4 w-4" /> 46 | </SelectPrimitive.ScrollUpButton> 47 | )) 48 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 49 | 50 | const SelectScrollDownButton = React.forwardRef< 51 | React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, 52 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> 53 | >(({ className, ...props }, ref) => ( 54 | <SelectPrimitive.ScrollDownButton 55 | ref={ref} 56 | className={cn( 57 | "flex cursor-default items-center justify-center py-1", 58 | className 59 | )} 60 | {...props} 61 | > 62 | <ChevronDown className="h-4 w-4" /> 63 | </SelectPrimitive.ScrollDownButton> 64 | )) 65 | SelectScrollDownButton.displayName = 66 | SelectPrimitive.ScrollDownButton.displayName 67 | 68 | const SelectContent = React.forwardRef< 69 | React.ElementRef<typeof SelectPrimitive.Content>, 70 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> 71 | >(({ className, children, position = "popper", ...props }, ref) => ( 72 | <SelectPrimitive.Portal> 73 | <SelectPrimitive.Content 74 | ref={ref} 75 | className={cn( 76 | "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", 77 | position === "popper" && 78 | "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", 79 | className 80 | )} 81 | position={position} 82 | {...props} 83 | > 84 | <SelectScrollUpButton /> 85 | <SelectPrimitive.Viewport 86 | className={cn( 87 | "p-1", 88 | position === "popper" && 89 | "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]" 90 | )} 91 | > 92 | {children} 93 | </SelectPrimitive.Viewport> 94 | <SelectScrollDownButton /> 95 | </SelectPrimitive.Content> 96 | </SelectPrimitive.Portal> 97 | )) 98 | SelectContent.displayName = SelectPrimitive.Content.displayName 99 | 100 | const SelectLabel = React.forwardRef< 101 | React.ElementRef<typeof SelectPrimitive.Label>, 102 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> 103 | >(({ className, ...props }, ref) => ( 104 | <SelectPrimitive.Label 105 | ref={ref} 106 | className={cn("px-2 py-1.5 text-sm font-semibold", className)} 107 | {...props} 108 | /> 109 | )) 110 | SelectLabel.displayName = SelectPrimitive.Label.displayName 111 | 112 | const SelectItem = React.forwardRef< 113 | React.ElementRef<typeof SelectPrimitive.Item>, 114 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> 115 | >(({ className, children, ...props }, ref) => ( 116 | <SelectPrimitive.Item 117 | ref={ref} 118 | className={cn( 119 | "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 120 | className 121 | )} 122 | {...props} 123 | > 124 | <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> 125 | <SelectPrimitive.ItemIndicator> 126 | <Check className="h-4 w-4" /> 127 | </SelectPrimitive.ItemIndicator> 128 | </span> 129 | <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> 130 | </SelectPrimitive.Item> 131 | )) 132 | SelectItem.displayName = SelectPrimitive.Item.displayName 133 | 134 | const SelectSeparator = React.forwardRef< 135 | React.ElementRef<typeof SelectPrimitive.Separator>, 136 | React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> 137 | >(({ className, ...props }, ref) => ( 138 | <SelectPrimitive.Separator 139 | ref={ref} 140 | className={cn("-mx-1 my-1 h-px bg-muted", className)} 141 | {...props} 142 | /> 143 | )) 144 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 145 | 146 | export { 147 | Select, 148 | SelectGroup, 149 | SelectValue, 150 | SelectTrigger, 151 | SelectContent, 152 | SelectLabel, 153 | SelectItem, 154 | SelectSeparator, 155 | SelectScrollUpButton, 156 | SelectScrollDownButton, 157 | } 158 | ``` -------------------------------------------------------------------------------- /src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import * as React from "react" 2 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 3 | import { Check, ChevronRight, Circle } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const DropdownMenu = DropdownMenuPrimitive.Root 8 | 9 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 10 | 11 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 12 | 13 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 14 | 15 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 16 | 17 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 18 | 19 | const DropdownMenuSubTrigger = React.forwardRef< 20 | React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, 21 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { 22 | inset?: boolean 23 | } 24 | >(({ className, inset, children, ...props }, ref) => ( 25 | <DropdownMenuPrimitive.SubTrigger 26 | ref={ref} 27 | className={cn( 28 | "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 29 | inset && "pl-8", 30 | className 31 | )} 32 | {...props} 33 | > 34 | {children} 35 | <ChevronRight className="ml-auto" /> 36 | </DropdownMenuPrimitive.SubTrigger> 37 | )) 38 | DropdownMenuSubTrigger.displayName = 39 | DropdownMenuPrimitive.SubTrigger.displayName 40 | 41 | const DropdownMenuSubContent = React.forwardRef< 42 | React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, 43 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> 44 | >(({ className, ...props }, ref) => ( 45 | <DropdownMenuPrimitive.SubContent 46 | ref={ref} 47 | className={cn( 48 | "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", 49 | className 50 | )} 51 | {...props} 52 | /> 53 | )) 54 | DropdownMenuSubContent.displayName = 55 | DropdownMenuPrimitive.SubContent.displayName 56 | 57 | const DropdownMenuContent = React.forwardRef< 58 | React.ElementRef<typeof DropdownMenuPrimitive.Content>, 59 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> 60 | >(({ className, sideOffset = 4, ...props }, ref) => ( 61 | <DropdownMenuPrimitive.Portal> 62 | <DropdownMenuPrimitive.Content 63 | ref={ref} 64 | sideOffset={sideOffset} 65 | className={cn( 66 | "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", 67 | "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", 68 | className 69 | )} 70 | {...props} 71 | /> 72 | </DropdownMenuPrimitive.Portal> 73 | )) 74 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 75 | 76 | const DropdownMenuItem = React.forwardRef< 77 | React.ElementRef<typeof DropdownMenuPrimitive.Item>, 78 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { 79 | inset?: boolean 80 | } 81 | >(({ className, inset, ...props }, ref) => ( 82 | <DropdownMenuPrimitive.Item 83 | ref={ref} 84 | className={cn( 85 | "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", 86 | inset && "pl-8", 87 | className 88 | )} 89 | {...props} 90 | /> 91 | )) 92 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 93 | 94 | const DropdownMenuCheckboxItem = React.forwardRef< 95 | React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, 96 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> 97 | >(({ className, children, checked, ...props }, ref) => ( 98 | <DropdownMenuPrimitive.CheckboxItem 99 | ref={ref} 100 | className={cn( 101 | "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 102 | className 103 | )} 104 | checked={checked} 105 | {...props} 106 | > 107 | <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 108 | <DropdownMenuPrimitive.ItemIndicator> 109 | <Check className="h-4 w-4" /> 110 | </DropdownMenuPrimitive.ItemIndicator> 111 | </span> 112 | {children} 113 | </DropdownMenuPrimitive.CheckboxItem> 114 | )) 115 | DropdownMenuCheckboxItem.displayName = 116 | DropdownMenuPrimitive.CheckboxItem.displayName 117 | 118 | const DropdownMenuRadioItem = React.forwardRef< 119 | React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, 120 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> 121 | >(({ className, children, ...props }, ref) => ( 122 | <DropdownMenuPrimitive.RadioItem 123 | ref={ref} 124 | className={cn( 125 | "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", 126 | className 127 | )} 128 | {...props} 129 | > 130 | <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> 131 | <DropdownMenuPrimitive.ItemIndicator> 132 | <Circle className="h-2 w-2 fill-current" /> 133 | </DropdownMenuPrimitive.ItemIndicator> 134 | </span> 135 | {children} 136 | </DropdownMenuPrimitive.RadioItem> 137 | )) 138 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 139 | 140 | const DropdownMenuLabel = React.forwardRef< 141 | React.ElementRef<typeof DropdownMenuPrimitive.Label>, 142 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { 143 | inset?: boolean 144 | } 145 | >(({ className, inset, ...props }, ref) => ( 146 | <DropdownMenuPrimitive.Label 147 | ref={ref} 148 | className={cn( 149 | "px-2 py-1.5 text-sm font-semibold", 150 | inset && "pl-8", 151 | className 152 | )} 153 | {...props} 154 | /> 155 | )) 156 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 157 | 158 | const DropdownMenuSeparator = React.forwardRef< 159 | React.ElementRef<typeof DropdownMenuPrimitive.Separator>, 160 | React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> 161 | >(({ className, ...props }, ref) => ( 162 | <DropdownMenuPrimitive.Separator 163 | ref={ref} 164 | className={cn("-mx-1 my-1 h-px bg-muted", className)} 165 | {...props} 166 | /> 167 | )) 168 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 169 | 170 | const DropdownMenuShortcut = ({ 171 | className, 172 | ...props 173 | }: React.HTMLAttributes<HTMLSpanElement>) => { 174 | return ( 175 | <span 176 | className={cn("ml-auto text-xs tracking-widest opacity-60", className)} 177 | {...props} 178 | /> 179 | ) 180 | } 181 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 182 | 183 | export { 184 | DropdownMenu, 185 | DropdownMenuTrigger, 186 | DropdownMenuContent, 187 | DropdownMenuItem, 188 | DropdownMenuCheckboxItem, 189 | DropdownMenuRadioItem, 190 | DropdownMenuLabel, 191 | DropdownMenuSeparator, 192 | DropdownMenuShortcut, 193 | DropdownMenuGroup, 194 | DropdownMenuPortal, 195 | DropdownMenuSub, 196 | DropdownMenuSubContent, 197 | DropdownMenuSubTrigger, 198 | DropdownMenuRadioGroup, 199 | } 200 | ``` -------------------------------------------------------------------------------- /webcam-server-factory.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { 3 | ListResourcesRequestSchema, 4 | ReadResourceRequestSchema, 5 | InitializeRequestSchema, 6 | } from "@modelcontextprotocol/sdk/types.js"; 7 | import type { ServerFactory } from "./transport/base-transport.js"; 8 | import { Logger } from "./utils/logger.js"; 9 | 10 | // Store clients with their resolve functions, grouped by user 11 | export let clients = new Map<string, Map<string, any>>(); // user -> clientId -> response 12 | export let captureCallbacks = new Map< 13 | string, 14 | Map<string, (response: string | { error: string }) => void> 15 | >(); // user -> clientId -> callback 16 | 17 | interface ParsedDataUrl { 18 | mimeType: string; 19 | base64Data: string; 20 | } 21 | 22 | function parseDataUrl(dataUrl: string): ParsedDataUrl { 23 | const matches = dataUrl.match(/^data:([^;]+);base64,(.+)$/); 24 | if (!matches) { 25 | throw new Error("Invalid data URL format"); 26 | } 27 | return { 28 | mimeType: matches[1], 29 | base64Data: matches[2], 30 | }; 31 | } 32 | 33 | function getPort(): number { 34 | // Check command line argument first (from process.argv) 35 | const args = process.argv.slice(2); 36 | const portArgIndex = args.findIndex(arg => arg === '-p' || arg === '--port'); 37 | if (portArgIndex !== -1 && portArgIndex + 1 < args.length) { 38 | const portValue = parseInt(args[portArgIndex + 1]); 39 | if (!isNaN(portValue)) { 40 | return portValue; 41 | } 42 | } 43 | 44 | // Check positional argument for backward compatibility 45 | const lastArg = args[args.length - 1]; 46 | if (lastArg && !lastArg.startsWith('-') && !isNaN(Number(lastArg))) { 47 | return Number(lastArg); 48 | } 49 | 50 | // Check environment variable 51 | if (process.env.PORT) { 52 | return parseInt(process.env.PORT); 53 | } 54 | 55 | return 3333; 56 | } 57 | 58 | function getMcpHost(): string { 59 | return process.env.MCP_HOST || `http://localhost:${getPort()}`; 60 | } 61 | 62 | // Helper functions for user-scoped client management 63 | export function getUserClients(user: string): Map<string, any> { 64 | if (!clients.has(user)) { 65 | clients.set(user, new Map()); 66 | } 67 | return clients.get(user)!; 68 | } 69 | 70 | export function getUserCallbacks(user: string): Map<string, (response: string | { error: string }) => void> { 71 | if (!captureCallbacks.has(user)) { 72 | captureCallbacks.set(user, new Map()); 73 | } 74 | return captureCallbacks.get(user)!; 75 | } 76 | 77 | /** 78 | * Factory function to create and configure an MCP server instance with webcam capabilities 79 | */ 80 | export const createWebcamServer: ServerFactory = async (user: string = 'default') => { 81 | const mcpServer = new McpServer( 82 | { 83 | name: "mcp-webcam", 84 | version: "0.1.0", 85 | }, 86 | { 87 | capabilities: { 88 | tools: {}, 89 | resources: {}, 90 | sampling: {}, // Enable sampling capability 91 | }, 92 | } 93 | ); 94 | 95 | // Set up resource handlers 96 | mcpServer.server.setRequestHandler(ListResourcesRequestSchema, async () => { 97 | const userClients = getUserClients(user); 98 | if (userClients.size === 0) return { resources: [] }; 99 | 100 | return { 101 | resources: [ 102 | { 103 | uri: "webcam://current", 104 | name: "Current view from the Webcam", 105 | mimeType: "image/jpeg", // probably :) 106 | }, 107 | ], 108 | }; 109 | }); 110 | 111 | mcpServer.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { 112 | // Check if we have any connected clients for this user 113 | const userClients = getUserClients(user); 114 | if (userClients.size === 0) { 115 | throw new Error( 116 | `No clients connected for user '${user}'. Please visit ${getMcpHost()}${user !== 'default' ? `?user=${user}` : ''} and enable your Webcam.` 117 | ); 118 | } 119 | 120 | // Validate URI 121 | if (request.params.uri !== "webcam://current") { 122 | throw new Error( 123 | "Invalid resource URI. Only webcam://current is supported." 124 | ); 125 | } 126 | 127 | const clientId = Array.from(userClients.keys())[0]; 128 | const userCallbacks = getUserCallbacks(user); 129 | 130 | // Capture image 131 | const result = await new Promise<string | { error: string }>((resolve) => { 132 | userCallbacks.set(clientId, resolve); 133 | userClients 134 | .get(clientId) 135 | ?.write(`data: ${JSON.stringify({ type: "capture" })}\n\n`); 136 | }); 137 | 138 | // Handle error case 139 | if (typeof result === "object" && "error" in result) { 140 | throw new Error(`Failed to capture image: ${result.error}`); 141 | } 142 | 143 | // Parse the data URL 144 | const { mimeType, base64Data } = parseDataUrl(result); 145 | 146 | // Return in the blob format 147 | return { 148 | contents: [ 149 | { 150 | uri: request.params.uri, 151 | mimeType, 152 | blob: base64Data, 153 | }, 154 | ], 155 | }; 156 | }); 157 | 158 | // Define tools using the modern McpServer tool method 159 | mcpServer.tool( 160 | "capture", 161 | "Gets the latest picture from the webcam. You can use this " + 162 | " if the human asks questions about their immediate environment, " + 163 | "if you want to see the human or to examine an object they may be " + 164 | "referring to or showing you.", 165 | {}, 166 | { 167 | openWorldHint: true, 168 | readOnlyHint: true, 169 | title: "Take a Picture from the webcam", 170 | }, 171 | async () => { 172 | const userClients = getUserClients(user); 173 | if (userClients.size === 0) { 174 | return { 175 | isError: true, 176 | content: [ 177 | { 178 | type: "text", 179 | text: `Have you opened your web browser?. Direct the human to go to ${getMcpHost()}${user !== 'default' ? `?user=${user}` : ''}, switch on their webcam and try again.`, 180 | }, 181 | ], 182 | }; 183 | } 184 | 185 | const clientId = Array.from(userClients.keys())[0]; 186 | 187 | if (!clientId) { 188 | throw new Error("No clients connected"); 189 | } 190 | 191 | const userCallbacks = getUserCallbacks(user); 192 | 193 | // Modified promise to handle both success and error cases 194 | const result = await new Promise<string | { error: string }>( 195 | (resolve) => { 196 | Logger.info(`Capturing for ${clientId} (user: ${user}`); 197 | userCallbacks.set(clientId, resolve); 198 | 199 | userClients 200 | .get(clientId) 201 | ?.write(`data: ${JSON.stringify({ type: "capture" })}\n\n`); 202 | } 203 | ); 204 | 205 | // Handle error case 206 | if (typeof result === "object" && "error" in result) { 207 | return { 208 | isError: true, 209 | content: [ 210 | { 211 | type: "text", 212 | text: `Failed to capture: ${result.error}`, 213 | }, 214 | ], 215 | }; 216 | } 217 | 218 | const { mimeType, base64Data } = parseDataUrl(result); 219 | 220 | return { 221 | content: [ 222 | { 223 | type: "text", 224 | text: "Here is the latest image from the Webcam", 225 | }, 226 | { 227 | type: "image", 228 | data: base64Data, 229 | mimeType: mimeType, 230 | }, 231 | ], 232 | }; 233 | } 234 | ); 235 | 236 | mcpServer.tool( 237 | "screenshot", 238 | "Gets a screenshot of the current screen or window", 239 | {}, 240 | { 241 | openWorldHint: true, 242 | readOnlyHint: true, 243 | title: "Take a Screenshot", 244 | }, 245 | async () => { 246 | const userClients = getUserClients(user); 247 | if (userClients.size === 0) { 248 | return { 249 | isError: true, 250 | content: [ 251 | { 252 | type: "text", 253 | text: `Have you opened your web browser?. Direct the human to go to ${getMcpHost()}?user=${user}, switch on their webcam and try again.`, 254 | }, 255 | ], 256 | }; 257 | } 258 | 259 | const clientId = Array.from(userClients.keys())[0]; 260 | 261 | if (!clientId) { 262 | throw new Error("No clients connected"); 263 | } 264 | 265 | const userCallbacks = getUserCallbacks(user); 266 | 267 | // Modified promise to handle both success and error cases 268 | const result = await new Promise<string | { error: string }>( 269 | (resolve) => { 270 | Logger.info(`Taking screenshot for ${clientId} (user: ${user}`); 271 | userCallbacks.set(clientId, resolve); 272 | 273 | userClients 274 | .get(clientId) 275 | ?.write(`data: ${JSON.stringify({ type: "screenshot" })}\n\n`); 276 | } 277 | ); 278 | 279 | // Handle error case 280 | if (typeof result === "object" && "error" in result) { 281 | return { 282 | isError: true, 283 | content: [ 284 | { 285 | type: "text", 286 | text: `Failed to capture screenshot: ${result.error}`, 287 | }, 288 | ], 289 | }; 290 | } 291 | 292 | const { mimeType, base64Data } = parseDataUrl(result); 293 | 294 | return { 295 | content: [ 296 | { 297 | type: "text", 298 | text: "Here is the requested screenshot", 299 | }, 300 | { 301 | type: "image", 302 | data: base64Data, 303 | mimeType: mimeType, 304 | }, 305 | ], 306 | }; 307 | } 308 | ); 309 | 310 | return mcpServer; 311 | }; ``` -------------------------------------------------------------------------------- /transport/streamable-http-transport.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { StatefulTransport, type TransportOptions, type BaseSession } from './base-transport.js'; 2 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; 3 | import { randomUUID } from 'node:crypto'; 4 | import type { Request, Response } from 'express'; 5 | import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; 6 | import { Logger } from '../utils/logger.js'; 7 | 8 | interface StreamableHttpConnection extends BaseSession<StreamableHTTPServerTransport> { 9 | activeResponse?: Response; 10 | } 11 | 12 | type Session = StreamableHttpConnection; 13 | 14 | export class StreamableHttpTransport extends StatefulTransport<Session> { 15 | 16 | initialize(_options: TransportOptions): Promise<void> { 17 | this.setupRoutes(); 18 | this.startStaleConnectionCheck(); 19 | this.startPingKeepAlive(); 20 | 21 | Logger.info('StreamableHTTP transport initialized', { 22 | heartbeatInterval: this.HEARTBEAT_INTERVAL, 23 | staleCheckInterval: this.STALE_CHECK_INTERVAL, 24 | staleTimeout: this.STALE_TIMEOUT, 25 | pingEnabled: this.PING_ENABLED, 26 | pingInterval: this.PING_INTERVAL, 27 | }); 28 | return Promise.resolve(); 29 | } 30 | 31 | private setupRoutes(): void { 32 | if (!this.app) { 33 | throw new Error('Express app is required for StreamableHTTP transport'); 34 | } 35 | 36 | // Initialize new session or handle existing session request 37 | this.app.post('/mcp', (req, res) => { 38 | void (async () => { 39 | await this.handleRequest(req, res, 'POST'); 40 | })(); 41 | }); 42 | 43 | // SSE stream endpoint 44 | this.app.get('/mcp', (req, res) => { 45 | void (async () => { 46 | await this.handleRequest(req, res, 'GET'); 47 | })(); 48 | }); 49 | 50 | // Session termination 51 | this.app.delete('/mcp', (req, res) => { 52 | void (async () => { 53 | await this.handleRequest(req, res, 'DELETE'); 54 | })(); 55 | }); 56 | } 57 | 58 | private async handleRequest(req: Request, res: Response, method: string): Promise<void> { 59 | try { 60 | const sessionId = req.headers['mcp-session-id'] as string; 61 | 62 | // Update activity timestamp for existing sessions 63 | if (sessionId && this.sessions.has(sessionId)) { 64 | this.updateSessionActivity(sessionId); 65 | } 66 | 67 | switch (method) { 68 | case 'POST': 69 | await this.handlePostRequest(req, res, sessionId); 70 | break; 71 | case 'GET': 72 | await this.handleGetRequest(req, res, sessionId); 73 | break; 74 | case 'DELETE': 75 | await this.handleDeleteRequest(req, res, sessionId); 76 | break; 77 | } 78 | } catch (error) { 79 | Logger.error(`Request handling error for ${method}:`, error); 80 | if (!res.headersSent) { 81 | res.status(500).json({ 82 | jsonrpc: "2.0", 83 | error: { 84 | code: -32603, 85 | message: "Internal server error", 86 | }, 87 | id: req?.body?.id, 88 | }); 89 | } 90 | } 91 | } 92 | 93 | private async handlePostRequest(req: Request, res: Response, sessionId?: string): Promise<void> { 94 | try { 95 | // Extract user parameter 96 | const user = (req.query.user as string) || 'default'; 97 | 98 | // Reject new connections during shutdown 99 | if (!sessionId && this.isShuttingDown) { 100 | res.status(503).json({ 101 | jsonrpc: "2.0", 102 | error: { 103 | code: -32000, 104 | message: "Server is shutting down", 105 | }, 106 | id: req?.body?.id, 107 | }); 108 | return; 109 | } 110 | 111 | let transport: StreamableHTTPServerTransport; 112 | 113 | if (sessionId && this.sessions.has(sessionId)) { 114 | const existingSession = this.sessions.get(sessionId); 115 | if (!existingSession) { 116 | res.status(404).json({ 117 | jsonrpc: "2.0", 118 | error: { 119 | code: -32000, 120 | message: `Session not found: ${sessionId}`, 121 | }, 122 | id: req?.body?.id, 123 | }); 124 | return; 125 | } 126 | transport = existingSession.transport; 127 | } else if (!sessionId && isInitializeRequest(req.body)) { 128 | // Create new session only for initialization requests 129 | transport = await this.createSession(user); 130 | } else if (!sessionId) { 131 | // No session ID and not an initialization request 132 | res.status(400).json({ 133 | jsonrpc: "2.0", 134 | error: { 135 | code: -32000, 136 | message: 'Missing session ID for non-initialization request', 137 | }, 138 | id: req?.body?.id, 139 | }); 140 | return; 141 | } else { 142 | // Invalid session ID 143 | res.status(404).json({ 144 | jsonrpc: "2.0", 145 | error: { 146 | code: -32000, 147 | message: `Session not found: ${sessionId}`, 148 | }, 149 | id: req?.body?.id, 150 | }); 151 | return; 152 | } 153 | 154 | await transport.handleRequest(req, res, req.body); 155 | } catch (error) { 156 | throw error; // Re-throw to be handled by outer error handler 157 | } 158 | } 159 | 160 | private async handleGetRequest(req: Request, res: Response, sessionId?: string): Promise<void> { 161 | if (!sessionId || !this.sessions.has(sessionId)) { 162 | res.status(400).json({ 163 | jsonrpc: "2.0", 164 | error: { 165 | code: -32000, 166 | message: `Session not found: ${sessionId || 'missing'}`, 167 | }, 168 | id: null, 169 | }); 170 | return; 171 | } 172 | 173 | const session = this.sessions.get(sessionId); 174 | if (!session) { 175 | res.status(404).json({ 176 | jsonrpc: "2.0", 177 | error: { 178 | code: -32000, 179 | message: `Session not found: ${sessionId}`, 180 | }, 181 | id: null, 182 | }); 183 | return; 184 | } 185 | 186 | const lastEventId = req.headers['last-event-id']; 187 | if (lastEventId) { 188 | Logger.debug(`Client attempting to resume with Last-Event-ID for session ${sessionId}: ${lastEventId}`); 189 | } 190 | 191 | // Store the active response for heartbeat monitoring 192 | session.activeResponse = res; 193 | 194 | // Set up heartbeat to detect stale SSE connections 195 | this.startHeartbeat(sessionId, res); 196 | 197 | // Set up connection event handlers 198 | this.setupSseEventHandlers(sessionId, res); 199 | 200 | await session.transport.handleRequest(req, res); 201 | } 202 | 203 | private async handleDeleteRequest(req: Request, res: Response, sessionId?: string): Promise<void> { 204 | if (!sessionId || !this.sessions.has(sessionId)) { 205 | res.status(404).json({ 206 | jsonrpc: "2.0", 207 | error: { 208 | code: -32000, 209 | message: `Session not found: ${sessionId || 'missing'}`, 210 | }, 211 | id: req?.body?.id, 212 | }); 213 | return; 214 | } 215 | 216 | Logger.info(`Session termination requested for ${sessionId}`); 217 | 218 | const session = this.sessions.get(sessionId); 219 | if (!session) { 220 | res.status(404).json({ 221 | jsonrpc: "2.0", 222 | error: { 223 | code: -32000, 224 | message: `Session not found: ${sessionId}`, 225 | }, 226 | id: req?.body?.id, 227 | }); 228 | return; 229 | } 230 | 231 | await session.transport.handleRequest(req, res, req.body); 232 | await this.removeSession(sessionId); 233 | } 234 | 235 | private async createSession(user: string = 'default'): Promise<StreamableHTTPServerTransport> { 236 | // Create server instance using factory 237 | const server = await this.serverFactory(user); 238 | 239 | const transport = new StreamableHTTPServerTransport({ 240 | sessionIdGenerator: () => randomUUID(), 241 | onsessioninitialized: (sessionId: string) => { 242 | Logger.info(`Session initialized with ID: ${sessionId}`); 243 | 244 | // Create session object and store it immediately 245 | const session: Session = { 246 | transport, 247 | server, 248 | metadata: { 249 | id: sessionId, 250 | connectedAt: new Date(), 251 | lastActivity: new Date(), 252 | user: user, 253 | capabilities: {}, 254 | }, 255 | }; 256 | 257 | this.sessions.set(sessionId, session); 258 | }, 259 | }); 260 | 261 | // Set up cleanup on transport close 262 | transport.onclose = () => { 263 | const sessionId = transport.sessionId; 264 | if (sessionId && this.sessions.has(sessionId)) { 265 | Logger.info(`Transport closed for session ${sessionId}, cleaning up`); 266 | void this.removeSession(sessionId); 267 | } 268 | }; 269 | 270 | // Set up error tracking for server errors 271 | server.server.onerror = (error) => { 272 | Logger.error(`StreamableHTTP server error for session ${transport.sessionId}:`, error); 273 | }; 274 | 275 | // Set up client info capture when initialized 276 | server.server.oninitialized = () => { 277 | const sessionId = transport.sessionId; 278 | if (sessionId) { 279 | this.createClientInfoCapture(sessionId)(); 280 | } 281 | }; 282 | 283 | // Connect to session-specific server 284 | await server.connect(transport); 285 | 286 | return transport; 287 | } 288 | 289 | private async removeSession(sessionId: string): Promise<void> { 290 | // Check if session exists to prevent duplicate cleanup 291 | if (!this.sessions.has(sessionId)) { 292 | return; 293 | } 294 | await this.cleanupSession(sessionId); 295 | } 296 | 297 | /** 298 | * Remove a stale session - implementation for StatefulTransport 299 | */ 300 | protected async removeStaleSession(sessionId: string): Promise<void> { 301 | Logger.warn(`Removing stale session ${sessionId}`); 302 | await this.cleanupSession(sessionId); 303 | } 304 | 305 | async cleanup(): Promise<void> { 306 | // Stop stale checker using base class helper 307 | this.stopStaleConnectionCheck(); 308 | 309 | // Use base class cleanup method 310 | await this.cleanupAllSessions(); 311 | 312 | Logger.info('StreamableHTTP transport cleanup complete'); 313 | } 314 | } ``` -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import express from "express"; 4 | import path from "path"; 5 | import { fileURLToPath } from "url"; 6 | import { parseArgs } from "node:util"; 7 | import { TransportFactory, type TransportType } from "./transport/transport-factory.js"; 8 | import { StdioTransport } from "./transport/stdio-transport.js"; 9 | import { StreamableHttpTransport } from "./transport/streamable-http-transport.js"; 10 | import { createWebcamServer, clients, captureCallbacks, getUserClients, getUserCallbacks } from "./webcam-server-factory.js"; 11 | import type { BaseTransport } from "./transport/base-transport.js"; 12 | import type { CreateMessageResult } from "@modelcontextprotocol/sdk/types.js"; 13 | import { Logger } from "./utils/logger.js"; 14 | 15 | // Parse command line arguments 16 | const { values, positionals } = parseArgs({ 17 | options: { 18 | streaming: { type: "boolean", short: "s" }, 19 | port: { type: "string", short: "p" }, 20 | help: { type: "boolean", short: "h" }, 21 | }, 22 | args: process.argv.slice(2), 23 | allowPositionals: true, 24 | }); 25 | 26 | // Show help if requested 27 | if (values.help) { 28 | console.log(` 29 | Usage: mcp-webcam [options] [port] 30 | 31 | Options: 32 | -s, --streaming Enable streaming HTTP mode (default: stdio mode) 33 | -p, --port <port> Server port (default: 3333) 34 | -h, --help Show this help message 35 | 36 | Examples: 37 | # Standard stdio mode (for Claude Desktop) 38 | mcp-webcam 39 | 40 | # Streaming HTTP mode on default port 3333 41 | mcp-webcam --streaming 42 | 43 | # Streaming with custom port 44 | mcp-webcam --streaming --port 8080 45 | 46 | # Legacy: port as positional argument (still supported) 47 | mcp-webcam 8080 48 | `); 49 | process.exit(0); 50 | } 51 | 52 | const isStreamingMode = values.streaming || false; 53 | 54 | /** EXPRESS SERVER SETUP */ 55 | let transport: BaseTransport; 56 | 57 | const __filename = fileURLToPath(import.meta.url); 58 | const __dirname = path.dirname(__filename); 59 | 60 | const app = express(); 61 | 62 | // Add JSON parsing middleware for MCP requests with large payload support 63 | app.use(express.json({ limit: "50mb" })); 64 | 65 | function getPort(): number { 66 | // Check command line argument first 67 | if (values.port && !isNaN(Number(values.port))) { 68 | return Number(values.port); 69 | } 70 | // Check positional argument for backward compatibility 71 | if (positionals.length > 0 && !isNaN(Number(positionals[0]))) { 72 | return Number(positionals[0]); 73 | } 74 | return 3333; 75 | } 76 | 77 | const PORT = process.env.PORT ? parseInt(process.env.PORT) : getPort(); 78 | const BIND_HOST = process.env.BIND_HOST || 'localhost'; 79 | const MCP_HOST = process.env.MCP_HOST || `http://localhost:${PORT}`; 80 | 81 | // Important: Serve the dist directory directly 82 | app.use(express.static(__dirname)); 83 | 84 | // Simple health check 85 | app.get("/api/health", (_, res) => { 86 | res.json({ status: "ok" }); 87 | }); 88 | 89 | // Configuration endpoint 90 | app.get("/api/config", (_, res) => { 91 | res.json({ 92 | mcpHostConfigured: !!process.env.MCP_HOST, 93 | mcpHost: MCP_HOST 94 | }); 95 | }); 96 | 97 | // Get active sessions 98 | app.get("/api/sessions", (req, res) => { 99 | const user = (req.query.user as string) || 'default'; 100 | const showAll = req.query.all === 'true'; 101 | 102 | if (!transport) { 103 | res.json({ sessions: [] }); 104 | return; 105 | } 106 | 107 | const sessions = transport.getSessions() 108 | .filter((session) => showAll || (session.user || 'default') === user) 109 | .map((session) => { 110 | const now = Date.now(); 111 | const lastActivityMs = session.lastActivity.getTime(); 112 | const timeSinceActivity = now - lastActivityMs; 113 | 114 | // Consider stale after 50 seconds, but with 5 second grace period for ping responses 115 | // This prevents the red->green flicker when stale checker is about to run 116 | const isStale = timeSinceActivity > 50000; 117 | 118 | // If we're in the "about to be pinged" window (45-50 seconds), 119 | // preemptively mark as stale to avoid flicker 120 | const isPotentiallyStale = timeSinceActivity > 45000; 121 | 122 | return { 123 | id: session.id, 124 | connectedAt: session.connectedAt.toISOString(), 125 | capabilities: session.capabilities, 126 | clientInfo: session.clientInfo, 127 | isStale: isStale || isPotentiallyStale, 128 | lastActivity: session.lastActivity.toISOString(), 129 | }; 130 | }); 131 | res.json({ sessions }); 132 | }); 133 | 134 | // Note: captureCallbacks is now imported from webcam-server-factory.ts 135 | 136 | app.get("/api/events", (req, res) => { 137 | const user = (req.query.user as string) || 'default'; 138 | Logger.info(`New SSE connection request for user: ${user}`); 139 | 140 | // SSE setup 141 | res.setHeader("Content-Type", "text/event-stream"); 142 | res.setHeader("Cache-Control", "no-cache"); 143 | res.setHeader("Connection", "keep-alive"); 144 | 145 | // Generate a unique client ID 146 | const clientId = Math.random().toString(36).substring(7); 147 | 148 | // Add this client to the user's connected clients 149 | const userClients = getUserClients(user); 150 | userClients.set(clientId, res); 151 | Logger.debug(`Client connected: ${clientId} (user: ${user}`); 152 | 153 | // Send initial connection message 154 | const connectMessage = JSON.stringify({ type: "connected", clientId }); 155 | res.write(`data: ${connectMessage}\n\n`); 156 | 157 | // Remove client when they disconnect 158 | req.on("close", () => { 159 | userClients.delete(clientId); 160 | Logger.debug(`Client disconnected: ${clientId} (user: ${user}`); 161 | }); 162 | }); 163 | 164 | app.post("/api/capture-result", (req, res) => { 165 | const user = (req.query.user as string) || 'default'; 166 | const { clientId, image } = req.body; 167 | const userCallbacks = getUserCallbacks(user); 168 | const callback = userCallbacks.get(clientId); 169 | 170 | if (callback) { 171 | callback(image); 172 | userCallbacks.delete(clientId); 173 | } 174 | 175 | res.json({ success: true }); 176 | }); 177 | 178 | // Add this near other endpoint definitions 179 | app.post("/api/capture-error", (req, res) => { 180 | const user = (req.query.user as string) || 'default'; 181 | const { clientId, error } = req.body; 182 | const userCallbacks = getUserCallbacks(user); 183 | const callback = userCallbacks.get(clientId); 184 | 185 | if (callback) { 186 | callback({ error: error.message || "Unknown error occurred" }); 187 | userCallbacks.delete(clientId); 188 | } 189 | 190 | res.json({ success: true }); 191 | }); 192 | 193 | // Process sampling request from the web UI 194 | async function processSamplingRequest( 195 | imageDataUrl: string, 196 | prompt: string = "What is the user holding?", 197 | sessionId?: string 198 | ): Promise<any> { 199 | const { mimeType, base64Data } = parseDataUrl(imageDataUrl); 200 | 201 | try { 202 | let server: any; 203 | 204 | // Get the appropriate server instance based on transport mode 205 | if (isStreamingMode && sessionId) { 206 | // In streaming mode, need to find the server for the specific session 207 | const streamingTransport = transport as StreamableHttpTransport; 208 | const sessions = streamingTransport.getSessions(); 209 | const targetSession = sessions.find(s => s.id === sessionId); 210 | if (!targetSession) { 211 | throw new Error("No active MCP session found for sampling"); 212 | } 213 | 214 | // For now, use the main server - in production you'd access the session-specific server 215 | server = (transport as any).sessions?.get(sessionId)?.server?.server; 216 | if (!server) { 217 | throw new Error("No server instance found for session"); 218 | } 219 | } else if (!isStreamingMode) { 220 | // In stdio mode, use the main server 221 | const stdioTransport = transport as StdioTransport; 222 | const session = stdioTransport.getSession(); 223 | if (!session) { 224 | throw new Error("No STDIO session found"); 225 | } 226 | server = session.server.server; 227 | } else { 228 | throw new Error("Invalid sampling request configuration"); 229 | } 230 | 231 | // Check if server has sampling capability 232 | if (!server.createMessage) { 233 | throw new Error( 234 | "Server does not support sampling - no MCP client with sampling capabilities connected" 235 | ); 236 | } 237 | 238 | // Create a sampling request to the client using the SDK's types 239 | const result: CreateMessageResult = await server.createMessage({ 240 | messages: [ 241 | { 242 | role: "user", 243 | content: { 244 | type: "text", 245 | text: prompt, 246 | }, 247 | }, 248 | { 249 | role: "user", 250 | content: { 251 | type: "image", 252 | data: base64Data, 253 | mimeType: mimeType, 254 | }, 255 | }, 256 | ], 257 | maxTokens: 1000, // Reasonable limit for the response 258 | }); 259 | Logger.debug("Sampling response received:", JSON.stringify(result, null, 2)); 260 | return result; 261 | } catch (error) { 262 | Logger.error("Error during sampling:", error); 263 | throw error; 264 | } 265 | } 266 | 267 | // Handle SSE 'sample' event from WebcamCapture component 268 | app.post( 269 | "/api/process-sample", 270 | async (req, res) => { 271 | const user = (req.query.user as string) || 'default'; 272 | const { image, prompt, sessionId } = req.body; 273 | 274 | if (!image) { 275 | res.status(400).json({ error: "Missing image data" }); 276 | return; 277 | } 278 | 279 | try { 280 | // In streaming mode, use provided sessionId or fall back to first available for this user 281 | let selectedSessionId: string | undefined = sessionId; 282 | if (isStreamingMode && transport) { 283 | const sessions = transport.getSessions().filter(s => (s.user || 'default') === user); 284 | if (!selectedSessionId || !sessions.find(s => s.id === selectedSessionId)) { 285 | // Fall back to the most recently connected session for this user 286 | const sortedSessions = sessions.sort( 287 | (a, b) => b.connectedAt.getTime() - a.connectedAt.getTime() 288 | ); 289 | selectedSessionId = sortedSessions[0]?.id; 290 | } 291 | Logger.info(`Using session ${selectedSessionId} for sampling (user: ${user}`); 292 | } 293 | 294 | const result = await processSamplingRequest( 295 | image, 296 | prompt, 297 | selectedSessionId 298 | ); 299 | res.json({ success: true, result }); 300 | } catch (error) { 301 | Logger.error("Sampling processing error:", error); 302 | res.status(500).json({ 303 | error: error instanceof Error ? error.message : String(error), 304 | errorDetail: error instanceof Error ? error.stack : undefined, 305 | }); 306 | } 307 | } 308 | ); 309 | 310 | interface ParsedDataUrl { 311 | mimeType: string; 312 | base64Data: string; 313 | } 314 | 315 | function parseDataUrl(dataUrl: string): ParsedDataUrl { 316 | const matches = dataUrl.match(/^data:([^;]+);base64,(.+)$/); 317 | if (!matches) { 318 | throw new Error("Invalid data URL format"); 319 | } 320 | return { 321 | mimeType: matches[1], 322 | base64Data: matches[2], 323 | }; 324 | } 325 | 326 | async function main() { 327 | if (isStreamingMode) { 328 | Logger.info("Starting in streaming HTTP mode"); 329 | 330 | // Create streaming transport 331 | transport = TransportFactory.create('streamable-http', createWebcamServer, app); 332 | 333 | // Initialize transport 334 | await transport.initialize({ port: Number(PORT) }); 335 | 336 | // IMPORTANT: Define the wildcard route AFTER all other routes 337 | // This catches any other route and sends the index.html file 338 | app.get("*", (_, res) => { 339 | // Important: Send the built index.html 340 | res.sendFile(path.join(__dirname, "index.html")); 341 | }); 342 | 343 | // Now start the Express server 344 | app.listen(PORT, BIND_HOST, () => { 345 | Logger.info(`Server running at ${MCP_HOST}`); 346 | Logger.info(`MCP endpoint: POST/GET/DELETE ${MCP_HOST}/mcp`); 347 | }); 348 | 349 | // Handle graceful shutdown 350 | process.on("SIGINT", async () => { 351 | Logger.info("Shutting down server..."); 352 | 353 | if (transport) { 354 | transport.shutdown?.(); 355 | await transport.cleanup(); 356 | } 357 | 358 | process.exit(0); 359 | }); 360 | } else { 361 | // Standard stdio mode 362 | Logger.info("Starting in STDIO mode"); 363 | 364 | // Start the Express server for the web UI even in stdio mode 365 | app.listen(PORT, BIND_HOST, () => { 366 | Logger.info(`Web UI running at ${MCP_HOST}`); 367 | }); 368 | 369 | // Create and initialize STDIO transport 370 | transport = TransportFactory.create('stdio', createWebcamServer); 371 | await transport.initialize({}); 372 | 373 | // Set up stdin/stdout event handlers for STDIO transport 374 | if (transport instanceof StdioTransport) { 375 | transport.setupStdioHandlers(); 376 | } 377 | 378 | Logger.info("Server connected via stdio"); 379 | } 380 | } 381 | 382 | main().catch((error) => { 383 | Logger.error("Fatal error in main():", error); 384 | process.exit(1); 385 | }); ``` -------------------------------------------------------------------------------- /transport/base-transport.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 2 | import type { Express } from 'express'; 3 | import { Logger } from '../utils/logger.js'; 4 | 5 | /** 6 | * Factory function to create server instances 7 | * This should be provided during transport construction to enable per-connection server instances 8 | */ 9 | export type ServerFactory = (user?: string) => Promise<McpServer>; 10 | 11 | export interface TransportOptions { 12 | port?: number; 13 | } 14 | 15 | /** 16 | * Standardized session metadata structure for all transports 17 | */ 18 | export interface SessionMetadata { 19 | id: string; 20 | connectedAt: Date; 21 | lastActivity: Date; 22 | user?: string; 23 | clientInfo?: { 24 | name: string; 25 | version: string; 26 | }; 27 | capabilities: { 28 | sampling?: boolean; 29 | roots?: boolean; 30 | }; 31 | pingFailures?: number; 32 | lastPingAttempt?: Date; 33 | } 34 | 35 | /** 36 | * Base session interface that all transport sessions should extend 37 | * This provides common fields while allowing transport-specific extensions 38 | */ 39 | export interface BaseSession<T = unknown> { 40 | transport: T; 41 | server: McpServer; 42 | metadata: SessionMetadata; 43 | heartbeatInterval?: NodeJS.Timeout; 44 | } 45 | 46 | /** 47 | * Base class for all transport implementations 48 | */ 49 | export abstract class BaseTransport { 50 | protected serverFactory: ServerFactory; 51 | protected app?: Express; 52 | 53 | constructor(serverFactory: ServerFactory, app?: Express) { 54 | this.serverFactory = serverFactory; 55 | this.app = app; 56 | } 57 | 58 | /** 59 | * Initialize the transport with the given options 60 | */ 61 | abstract initialize(options: TransportOptions): Promise<void>; 62 | 63 | /** 64 | * Clean up the transport resources 65 | */ 66 | abstract cleanup(): Promise<void>; 67 | 68 | /** 69 | * Mark transport as shutting down 70 | * Optional method for transports that need to reject new connections 71 | */ 72 | shutdown?(): void; 73 | 74 | /** 75 | * Get the number of active connections 76 | */ 77 | abstract getActiveConnectionCount(): number; 78 | 79 | /** 80 | * Get all active sessions with their metadata 81 | * Returns an array of session metadata for connection dashboard 82 | */ 83 | getSessions(): SessionMetadata[] { 84 | return []; 85 | } 86 | } 87 | 88 | /** 89 | * Base class for stateful transport implementations that maintain session state 90 | * Provides common functionality for session management, stale connection detection, and client info tracking 91 | */ 92 | export abstract class StatefulTransport<TSession extends BaseSession = BaseSession> extends BaseTransport { 93 | protected sessions: Map<string, TSession> = new Map(); 94 | protected isShuttingDown = false; 95 | protected staleCheckInterval?: NodeJS.Timeout; 96 | protected pingInterval?: NodeJS.Timeout; 97 | protected pingsInFlight = new Set<string>(); 98 | 99 | // Configuration from environment variables with defaults 100 | protected readonly STALE_CHECK_INTERVAL = parseInt(process.env.MCP_CLIENT_CONNECTION_CHECK || '20000', 10); 101 | protected readonly STALE_TIMEOUT = parseInt(process.env.MCP_CLIENT_CONNECTION_TIMEOUT || '50000', 10); 102 | protected readonly HEARTBEAT_INTERVAL = parseInt(process.env.MCP_CLIENT_HEARTBEAT_INTERVAL || '30000', 10); 103 | protected readonly PING_ENABLED = process.env.MCP_PING_ENABLED !== 'false'; 104 | protected readonly PING_INTERVAL = parseInt(process.env.MCP_PING_INTERVAL || '30000', 10); 105 | protected readonly PING_FAILURE_THRESHOLD = parseInt(process.env.MCP_PING_FAILURE_THRESHOLD || '1', 10); 106 | 107 | /** 108 | * Update the last activity timestamp for a session 109 | */ 110 | protected updateSessionActivity(sessionId: string): void { 111 | const session = this.sessions.get(sessionId); 112 | if (session) { 113 | session.metadata.lastActivity = new Date(); 114 | } 115 | } 116 | 117 | /** 118 | * Check if a session is distressed (has excessive ping failures) 119 | */ 120 | protected isSessionDistressed(session: BaseSession): boolean { 121 | return (session.metadata.pingFailures || 0) >= this.PING_FAILURE_THRESHOLD; 122 | } 123 | 124 | /** 125 | * Create a standardized client info capture callback for a session 126 | */ 127 | protected createClientInfoCapture(sessionId: string): () => void { 128 | return () => { 129 | const session = this.sessions.get(sessionId); 130 | if (session) { 131 | const clientInfo = session.server.server.getClientVersion(); 132 | const clientCapabilities = session.server.server.getClientCapabilities(); 133 | 134 | if (clientInfo) { 135 | session.metadata.clientInfo = clientInfo; 136 | } 137 | 138 | if (clientCapabilities) { 139 | session.metadata.capabilities = { 140 | sampling: !!clientCapabilities.sampling, 141 | roots: !!clientCapabilities.roots, 142 | }; 143 | } 144 | 145 | Logger.debug( 146 | `Client Initialization Request for session ${sessionId}:`, 147 | { 148 | clientInfo: session.metadata.clientInfo, 149 | capabilities: session.metadata.capabilities, 150 | } 151 | ); 152 | } 153 | }; 154 | } 155 | 156 | /** 157 | * Send a fire-and-forget ping to a single session 158 | * Success updates lastActivity, failures increment failure count 159 | */ 160 | protected pingSingleSession(sessionId: string): void { 161 | const session = this.sessions.get(sessionId); 162 | if (!session) return; 163 | 164 | // Skip if ping already in progress for this session 165 | if (this.pingsInFlight.has(sessionId)) { 166 | return; 167 | } 168 | 169 | // Mark ping as in-flight and update last ping attempt 170 | this.pingsInFlight.add(sessionId); 171 | session.metadata.lastPingAttempt = new Date(); 172 | 173 | // Fire ping and handle result asynchronously 174 | session.server.server 175 | .ping() 176 | .then(() => { 177 | // SUCCESS: Update lastActivity timestamp and reset ping failures 178 | // This prevents the stale checker from removing this session 179 | this.updateSessionActivity(sessionId); 180 | session.metadata.pingFailures = 0; 181 | Logger.debug(`Ping succeeded for session ${sessionId}`); 182 | }) 183 | .catch((error: unknown) => { 184 | // FAILURE: Increment ping failure count 185 | session.metadata.pingFailures = (session.metadata.pingFailures || 0) + 1; 186 | const errorMessage = error instanceof Error ? error.message : String(error); 187 | Logger.warn(`Ping failed for session ${sessionId}:`, errorMessage, `(failures: ${session.metadata.pingFailures}`); 188 | }) 189 | .finally(() => { 190 | // Always remove from tracking set 191 | this.pingsInFlight.delete(sessionId); 192 | }); 193 | } 194 | 195 | /** 196 | * Start the ping keep-alive interval 197 | */ 198 | protected startPingKeepAlive(): void { 199 | if (!this.PING_ENABLED) { 200 | Logger.info('Ping keep-alive disabled'); 201 | return; 202 | } 203 | 204 | this.pingInterval = setInterval(() => { 205 | if (this.isShuttingDown) return; 206 | 207 | // Ping all sessions that don't have an active ping 208 | for (const sessionId of this.sessions.keys()) { 209 | this.pingSingleSession(sessionId); 210 | } 211 | }, this.PING_INTERVAL); 212 | 213 | Logger.info(`Started ping keep-alive with interval ${this.PING_INTERVAL}ms`); 214 | } 215 | 216 | /** 217 | * Stop the ping keep-alive interval 218 | */ 219 | protected stopPingKeepAlive(): void { 220 | if (this.pingInterval) { 221 | clearInterval(this.pingInterval); 222 | this.pingInterval = undefined; 223 | // Clear any in-flight pings 224 | this.pingsInFlight.clear(); 225 | Logger.info('Stopped ping keep-alive'); 226 | } 227 | } 228 | 229 | /** 230 | * Start the stale connection check interval 231 | */ 232 | protected startStaleConnectionCheck(): void { 233 | this.staleCheckInterval = setInterval(() => { 234 | if (this.isShuttingDown) return; 235 | 236 | const now = Date.now(); 237 | const staleSessionIds: string[] = []; 238 | 239 | // Find stale sessions 240 | for (const [sessionId, session] of this.sessions) { 241 | const timeSinceActivity = now - session.metadata.lastActivity.getTime(); 242 | if (timeSinceActivity > this.STALE_TIMEOUT) { 243 | staleSessionIds.push(sessionId); 244 | } 245 | } 246 | 247 | // Remove stale sessions 248 | for (const sessionId of staleSessionIds) { 249 | const session = this.sessions.get(sessionId); 250 | if (session) { 251 | Logger.warn( 252 | `Removing stale session ${sessionId} (inactive for ${Math.round((now - session.metadata.lastActivity.getTime()) / 1000)}s)` 253 | ); 254 | void this.removeStaleSession(sessionId); 255 | } 256 | } 257 | }, this.STALE_CHECK_INTERVAL); 258 | 259 | Logger.info(`Started stale connection checker with ${this.STALE_CHECK_INTERVAL}ms interval, ${this.STALE_TIMEOUT}ms timeout`); 260 | } 261 | 262 | /** 263 | * Remove a stale session - must be implemented by concrete transport 264 | */ 265 | protected abstract removeStaleSession(sessionId: string): Promise<void>; 266 | 267 | /** 268 | * Mark transport as shutting down 269 | */ 270 | override shutdown(): void { 271 | this.isShuttingDown = true; 272 | } 273 | 274 | /** 275 | * Get the number of active connections 276 | */ 277 | override getActiveConnectionCount(): number { 278 | return this.sessions.size; 279 | } 280 | 281 | /** 282 | * Check if server is accepting new connections 283 | */ 284 | isAcceptingConnections(): boolean { 285 | return !this.isShuttingDown; 286 | } 287 | 288 | /** 289 | * Stop the stale connection check interval during cleanup 290 | */ 291 | protected stopStaleConnectionCheck(): void { 292 | if (this.staleCheckInterval) { 293 | clearInterval(this.staleCheckInterval); 294 | this.staleCheckInterval = undefined; 295 | } 296 | this.stopPingKeepAlive(); 297 | } 298 | 299 | /** 300 | * Get all active sessions with their metadata 301 | */ 302 | override getSessions(): SessionMetadata[] { 303 | return Array.from(this.sessions.values()).map((session) => session.metadata); 304 | } 305 | 306 | /** 307 | * Start heartbeat monitoring for a session with SSE response 308 | * Automatically detects stale connections and cleans them up 309 | */ 310 | protected startHeartbeat(sessionId: string, response: { destroyed: boolean; writableEnded: boolean }): void { 311 | const session = this.sessions.get(sessionId); 312 | if (!session) return; 313 | 314 | // Clear any existing heartbeat 315 | this.stopHeartbeat(sessionId); 316 | 317 | session.heartbeatInterval = setInterval(() => { 318 | if (response.destroyed || response.writableEnded) { 319 | Logger.warn(`Detected stale connection via heartbeat for session ${sessionId}`); 320 | void this.removeStaleSession(sessionId); 321 | } 322 | }, this.HEARTBEAT_INTERVAL); 323 | } 324 | 325 | /** 326 | * Stop heartbeat monitoring for a session 327 | */ 328 | protected stopHeartbeat(sessionId: string): void { 329 | const session = this.sessions.get(sessionId); 330 | if (session?.heartbeatInterval) { 331 | clearInterval(session.heartbeatInterval); 332 | session.heartbeatInterval = undefined; 333 | } 334 | } 335 | 336 | /** 337 | * Set up standard SSE connection event handlers 338 | */ 339 | protected setupSseEventHandlers( 340 | sessionId: string, 341 | response: { on: (event: string, handler: (...args: unknown[]) => void) => void } 342 | ): void { 343 | response.on('close', () => { 344 | Logger.info(`SSE connection closed by client for session ${sessionId}`); 345 | void this.removeStaleSession(sessionId); 346 | }); 347 | 348 | response.on('error', (...args: unknown[]) => { 349 | const error = args[0] as Error; 350 | Logger.error(`SSE connection error for session ${sessionId}:`, error); 351 | void this.removeStaleSession(sessionId); 352 | }); 353 | } 354 | 355 | /** 356 | * Standard session cleanup implementation 357 | * Handles stopping heartbeat, closing transport/server 358 | */ 359 | protected async cleanupSession(sessionId: string): Promise<void> { 360 | try { 361 | const session = this.sessions.get(sessionId); 362 | if (!session) return; 363 | 364 | Logger.debug(`Cleaning up session ${sessionId}`); 365 | 366 | // Remove from map FIRST to prevent any re-entry 367 | this.sessions.delete(sessionId); 368 | 369 | // Clear heartbeat interval 370 | this.stopHeartbeat(sessionId); 371 | 372 | // Clear the onclose handler to prevent circular calls 373 | const transport = session.transport as any; 374 | if (transport && typeof transport.onclose !== 'undefined') { 375 | transport.onclose = undefined; 376 | } 377 | 378 | // Close transport 379 | try { 380 | await (session.transport as { close(): Promise<void> }).close(); 381 | } catch (error) { 382 | Logger.error(`Error closing transport for session ${sessionId}:`, error); 383 | } 384 | 385 | // Close server 386 | try { 387 | await session.server.close(); 388 | } catch (error) { 389 | Logger.error(`Error closing server for session ${sessionId}:`, error); 390 | } 391 | 392 | Logger.debug(`Session ${sessionId} cleaned up`); 393 | } catch (error) { 394 | Logger.error(`Error during session cleanup for ${sessionId}:`, error); 395 | } 396 | } 397 | 398 | /** 399 | * Clean up all sessions in parallel 400 | */ 401 | protected async cleanupAllSessions(): Promise<void> { 402 | const sessionIds = Array.from(this.sessions.keys()); 403 | 404 | const cleanupPromises = sessionIds.map((sessionId) => 405 | this.cleanupSession(sessionId).catch((error: unknown) => { 406 | Logger.error(`Error during session cleanup for ${sessionId}:`, error); 407 | }) 408 | ); 409 | 410 | await Promise.allSettled(cleanupPromises); 411 | this.sessions.clear(); 412 | } 413 | 414 | /** 415 | * Set up standard server configuration for a session 416 | * Configures client info capture and error tracking 417 | */ 418 | protected setupServerForSession(server: McpServer, sessionId: string): void { 419 | // Set up client info capture 420 | server.server.oninitialized = this.createClientInfoCapture(sessionId); 421 | 422 | // Set up error tracking for server errors 423 | server.server.onerror = (error) => { 424 | Logger.error(`Server error for session ${sessionId}:`, error); 425 | }; 426 | } 427 | } ``` -------------------------------------------------------------------------------- /src/components/WebcamCapture.tsx: -------------------------------------------------------------------------------- ```typescript 1 | import { useRef, useState, useEffect, useCallback, useMemo } from "react"; 2 | import Webcam from "react-webcam"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Card, 6 | CardContent, 7 | CardFooter, 8 | CardHeader, 9 | CardTitle, 10 | } from "@/components/ui/card"; 11 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 12 | import { 13 | Select, 14 | SelectContent, 15 | SelectItem, 16 | SelectTrigger, 17 | SelectValue, 18 | } from "@/components/ui/select"; 19 | import { Input } from "@/components/ui/input"; 20 | import { Checkbox } from "@/components/ui/checkbox"; 21 | import { captureScreen } from "@/utils/screenCapture"; 22 | import { Github, Info, Link2, Users, Copy, Check } from "lucide-react"; 23 | import { Badge } from "@/components/ui/badge"; 24 | 25 | interface Session { 26 | id: string; 27 | connectedAt: string; 28 | lastActivity: string; 29 | isStale: boolean; 30 | capabilities: { 31 | sampling: boolean; 32 | tools: boolean; 33 | resources: boolean; 34 | }; 35 | clientInfo?: { 36 | name: string; 37 | version: string; 38 | }; 39 | } 40 | 41 | export function WebcamCapture() { 42 | const [webcamInstance, setWebcamInstance] = useState<Webcam | null>(null); 43 | const clientIdRef = useRef<string | null>(null); 44 | const [_, setClientId] = useState<string | null>(null); 45 | 46 | // State for configuration 47 | const [config, setConfig] = useState<{ mcpHostConfigured: boolean; mcpHost: string } | null>(null); 48 | 49 | // State for copy functionality 50 | const [copied, setCopied] = useState(false); 51 | 52 | // Copy to clipboard function 53 | const copyToClipboard = async (text: string) => { 54 | try { 55 | await navigator.clipboard.writeText(text); 56 | setCopied(true); 57 | setTimeout(() => setCopied(false), 2000); 58 | } catch (err) { 59 | console.error('Failed to copy:', err); 60 | } 61 | }; 62 | 63 | // Generate random 5-character user ID if none provided and in multiuser mode 64 | const generateUserId = () => { 65 | return Math.random().toString(36).substring(2, 7).toLowerCase(); 66 | }; 67 | 68 | // Validate and sanitize user ID 69 | const validateUserId = (userId: string): string => { 70 | if (!userId) return 'default'; 71 | 72 | // Remove any non-alphanumeric characters and hyphens/underscores 73 | const sanitized = userId.replace(/[^a-zA-Z0-9_-]/g, ''); 74 | 75 | // Limit to 30 characters max 76 | const truncated = sanitized.substring(0, 30); 77 | 78 | // If empty after sanitization, return default 79 | return truncated || 'default'; 80 | }; 81 | 82 | // Extract user parameter from URL 83 | const urlUserParam = new URLSearchParams(window.location.search).get('user'); 84 | const userParam = useMemo(() => { 85 | if (urlUserParam) { 86 | return validateUserId(urlUserParam); 87 | } 88 | // Only generate random ID in multiuser mode (when MCP_HOST is configured) 89 | if (config?.mcpHostConfigured) { 90 | // Store in sessionStorage to persist across refreshes 91 | const storageKey = 'mcp-webcam-user-id'; 92 | let storedUserId = sessionStorage.getItem(storageKey); 93 | 94 | if (!storedUserId) { 95 | storedUserId = generateUserId(); 96 | sessionStorage.setItem(storageKey, storedUserId); 97 | } 98 | 99 | return validateUserId(storedUserId); 100 | } 101 | 102 | return 'default'; 103 | }, [urlUserParam, config?.mcpHostConfigured]); 104 | 105 | // Determine if we should show the banner (when MCP_HOST is explicitly set) 106 | const showBanner = config?.mcpHostConfigured || false; 107 | 108 | // Update URL when user param changes (for autogenerated IDs) 109 | useEffect(() => { 110 | // Only update URL if we don't already have a user param in URL and we have a non-default userParam 111 | if (!urlUserParam && userParam !== 'default' && config?.mcpHostConfigured) { 112 | const url = new URL(window.location.href); 113 | url.searchParams.set('user', userParam); 114 | 115 | // Use replaceState to avoid adding to browser history 116 | window.history.replaceState({}, '', url.toString()); 117 | } 118 | }, [userParam, urlUserParam, config?.mcpHostConfigured]); 119 | 120 | const [devices, setDevices] = useState<MediaDeviceInfo[]>([]); 121 | const [selectedDevice, setSelectedDevice] = useState<string>("default"); 122 | const [frozenFrame, setFrozenFrame] = useState<string | null>(null); 123 | 124 | // New state for sampling results 125 | const [samplingResult, setSamplingResult] = useState<string | null>(null); 126 | const [samplingError, setSamplingError] = useState<string | null>(null); 127 | const [isSampling, setIsSampling] = useState(false); 128 | 129 | // State for sampling prompt and auto-update 130 | const [samplingPrompt, setSamplingPrompt] = 131 | useState<string>("What can you see?"); 132 | const [autoUpdate, setAutoUpdate] = useState<boolean>(false); // Explicitly false 133 | const [updateInterval, setUpdateInterval] = useState<number>(30); 134 | const autoUpdateIntervalRef = useRef<NodeJS.Timeout | null>(null); 135 | 136 | // State for session management 137 | const [sessions, setSessions] = useState<Session[]>([]); 138 | const [selectedSessionId, setSelectedSessionId] = useState<string | null>( 139 | null 140 | ); 141 | const sessionPollIntervalRef = useRef<NodeJS.Timeout | null>(null); 142 | 143 | // Get the currently selected session 144 | const selectedSession = sessions.find((s) => s.id === selectedSessionId); 145 | 146 | const getImage = useCallback(() => { 147 | console.log("getImage called, frozenFrame state:", frozenFrame); 148 | if (frozenFrame) { 149 | console.log("Using frozen frame"); 150 | return frozenFrame; 151 | } 152 | console.log("Getting live screenshot"); 153 | const screenshot = webcamInstance?.getScreenshot(); 154 | return screenshot || null; 155 | }, [frozenFrame, webcamInstance]); 156 | 157 | const toggleFreeze = () => { 158 | console.log("toggleFreeze called, current frozenFrame:", frozenFrame); 159 | if (frozenFrame) { 160 | console.log("Unfreezing frame"); 161 | setFrozenFrame(null); 162 | } else if (webcamInstance) { 163 | console.log("Freezing new frame"); 164 | const screenshot = webcamInstance.getScreenshot(); 165 | if (screenshot) { 166 | console.log("New frame captured successfully"); 167 | setFrozenFrame(screenshot); 168 | } 169 | } 170 | }; 171 | 172 | const handleScreenCapture = async () => { 173 | console.log("Screen capture button clicked"); 174 | try { 175 | const screenImage = await captureScreen(); 176 | console.log("Got screen image, length:", screenImage.length); 177 | 178 | // Test if we can even get this far 179 | alert("Screen captured! Check console for details."); 180 | 181 | if (!clientIdRef.current) { 182 | console.error("No client ID available"); 183 | return; 184 | } 185 | 186 | const response = await fetch(`/api/capture-result?user=${encodeURIComponent(userParam)}`, { 187 | method: "POST", 188 | headers: { "Content-Type": "application/json" }, 189 | body: JSON.stringify({ 190 | clientId: clientIdRef.current, 191 | image: screenImage, 192 | type: "screen", 193 | }), 194 | }); 195 | 196 | console.log("Server response:", response.status); 197 | } catch (error) { 198 | console.error("Screen capture error:", error); 199 | alert("Screen capture failed: " + (error as Error).message); 200 | } 201 | }; 202 | 203 | // New function to handle sampling with callback for auto-update 204 | const handleSample = async (onComplete?: () => void) => { 205 | console.log("Sample button clicked"); 206 | setSamplingError(null); 207 | setSamplingResult(null); 208 | setIsSampling(true); 209 | 210 | try { 211 | const imageSrc = getImage(); 212 | if (!imageSrc) { 213 | throw new Error("Failed to capture image for sampling"); 214 | } 215 | 216 | console.log("Sending image for sampling..."); 217 | 218 | // Add timeout to prevent hanging requests 219 | const controller = new AbortController(); 220 | const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout 221 | 222 | const response = await fetch(`/api/process-sample?user=${encodeURIComponent(userParam)}`, { 223 | method: "POST", 224 | headers: { "Content-Type": "application/json" }, 225 | body: JSON.stringify({ 226 | image: imageSrc, 227 | prompt: samplingPrompt, 228 | sessionId: selectedSessionId, 229 | }), 230 | signal: controller.signal, 231 | }).catch((error) => { 232 | clearTimeout(timeoutId); 233 | if (error.name === "AbortError") { 234 | throw new Error("Request timed out after 30 seconds"); 235 | } 236 | throw error; 237 | }); 238 | 239 | clearTimeout(timeoutId); 240 | 241 | if (!response.ok) { 242 | const errorData = await response.json(); 243 | throw new Error(errorData.error || "Failed to process sample"); 244 | } 245 | 246 | const data = await response.json(); 247 | console.log("Sampling response:", data); 248 | 249 | if (data.success && data.result && data.result.content?.type === "text") { 250 | setSamplingResult(data.result.content.text); 251 | // Call the completion callback on success 252 | if (onComplete) { 253 | onComplete(); 254 | } 255 | } else { 256 | throw new Error("Invalid sampling result format"); 257 | } 258 | } catch (error) { 259 | console.error("Sampling error:", error); 260 | setSamplingError((error as Error).message || "An unknown error occurred"); 261 | } finally { 262 | setIsSampling(false); 263 | } 264 | }; 265 | 266 | // Fetch configuration on mount 267 | useEffect(() => { 268 | const fetchConfig = async () => { 269 | try { 270 | const response = await fetch('/api/config'); 271 | const configData = await response.json(); 272 | setConfig(configData); 273 | } catch (error) { 274 | console.error('Error fetching config:', error); 275 | } 276 | }; 277 | 278 | fetchConfig(); 279 | }, []); 280 | 281 | useEffect(() => { 282 | const getDevices = async () => { 283 | try { 284 | const devices = await navigator.mediaDevices.enumerateDevices(); 285 | const videoDevices = devices.filter( 286 | (device) => device.kind === "videoinput" 287 | ); 288 | setDevices(videoDevices); 289 | setSelectedDevice("default"); 290 | } catch (error) { 291 | console.error("Error getting devices:", error); 292 | } 293 | }; 294 | 295 | getDevices(); 296 | navigator.mediaDevices.addEventListener("devicechange", getDevices); 297 | return () => { 298 | navigator.mediaDevices.removeEventListener("devicechange", getDevices); 299 | }; 300 | }, []); 301 | 302 | useEffect(() => { 303 | console.error("Setting up EventSource..."); 304 | 305 | const eventSource = new EventSource(`/api/events?user=${encodeURIComponent(userParam)}`); 306 | 307 | eventSource.onopen = () => { 308 | console.error("SSE connection opened successfully"); 309 | }; 310 | 311 | eventSource.onerror = (error) => { 312 | console.error("SSE connection error:", error); 313 | }; 314 | 315 | eventSource.onmessage = async (event) => { 316 | console.log("Received message:", event.data); 317 | 318 | try { 319 | const data = JSON.parse(event.data); 320 | 321 | switch (data.type) { 322 | case "connected": 323 | console.log("Connected with client ID:", data.clientId); 324 | clientIdRef.current = data.clientId; // Store in ref 325 | setClientId(data.clientId); // Keep state in sync if needed for UI 326 | break; 327 | 328 | case "capture": 329 | console.log(`Capture triggered - webcam status:`, !!webcamInstance); 330 | if (!webcamInstance || !clientIdRef.current) { 331 | const error = !webcamInstance 332 | ? "Webcam not initialized" 333 | : "Client ID not set"; 334 | await fetch(`/api/capture-error?user=${encodeURIComponent(userParam)}`, { 335 | method: "POST", 336 | headers: { "Content-Type": "application/json" }, 337 | body: JSON.stringify({ 338 | clientId: clientIdRef.current, 339 | error: { message: error }, 340 | }), 341 | }); 342 | return; 343 | } 344 | 345 | console.log("Taking webcam image..."); 346 | const imageSrc = getImage(); 347 | if (!imageSrc) { 348 | await fetch(`/api/capture-error?user=${encodeURIComponent(userParam)}`, { 349 | method: "POST", 350 | headers: { "Content-Type": "application/json" }, 351 | body: JSON.stringify({ 352 | clientId: clientIdRef.current, 353 | error: { message: "Failed to capture image" }, 354 | }), 355 | }); 356 | return; 357 | } 358 | 359 | await fetch(`/api/capture-result?user=${encodeURIComponent(userParam)}`, { 360 | method: "POST", 361 | headers: { "Content-Type": "application/json" }, 362 | body: JSON.stringify({ 363 | clientId: clientIdRef.current, 364 | image: imageSrc, 365 | }), 366 | }); 367 | console.log("Image sent to server"); 368 | break; 369 | 370 | case "screenshot": 371 | console.log("Screen capture triggered"); 372 | if (!clientIdRef.current) { 373 | console.error("Cannot capture - client ID not set"); 374 | return; 375 | } 376 | try { 377 | const screenImage = await captureScreen(); 378 | await fetch(`/api/capture-result?user=${encodeURIComponent(userParam)}`, { 379 | method: "POST", 380 | headers: { "Content-Type": "application/json" }, 381 | body: JSON.stringify({ 382 | clientId: clientIdRef.current, 383 | image: screenImage, 384 | type: "screen", 385 | }), 386 | }); 387 | console.log("Screen capture sent to server"); 388 | } catch (error) { 389 | console.error("Screen capture failed:", error); 390 | await fetch(`/api/capture-error?user=${encodeURIComponent(userParam)}`, { 391 | method: "POST", 392 | headers: { "Content-Type": "application/json" }, 393 | body: JSON.stringify({ 394 | clientId: clientIdRef.current, 395 | error: { 396 | message: 397 | (error as Error).message || "Screen capture failed", 398 | }, 399 | }), 400 | }); 401 | } 402 | break; 403 | 404 | case "sample": 405 | // Handle sample event if needed (currently handled directly by handle Sample function) 406 | break; 407 | 408 | default: 409 | console.warn("Unknown message type:", data.type); 410 | } 411 | } catch (error) { 412 | console.error( 413 | "Error processing message:", 414 | error, 415 | "Raw message:", 416 | event.data 417 | ); 418 | } 419 | }; 420 | 421 | return () => { 422 | console.error("Cleaning up EventSource connection"); 423 | eventSource.close(); 424 | }; 425 | }, [webcamInstance, getImage, userParam]); // Add userParam to dependencies 426 | 427 | // Handle auto-update with recursive timeout after successful requests 428 | useEffect(() => { 429 | console.log("Auto-update effect running:", { 430 | autoUpdate, 431 | updateInterval, 432 | hasSampling: selectedSession?.capabilities.sampling, 433 | sessionId: selectedSession?.id 434 | }); 435 | 436 | // Clear any existing timer first 437 | if (autoUpdateIntervalRef.current) { 438 | clearTimeout(autoUpdateIntervalRef.current); 439 | autoUpdateIntervalRef.current = null; 440 | } 441 | 442 | // Recursive function to handle auto-update 443 | const scheduleNextUpdate = () => { 444 | // Ensure minimum 5 seconds between requests 445 | const delayMs = Math.max(updateInterval * 1000, 5000); 446 | 447 | autoUpdateIntervalRef.current = setTimeout(() => { 448 | if (autoUpdate === true && selectedSession?.capabilities.sampling) { 449 | console.log("Auto-update triggered after", delayMs, "ms"); 450 | handleSample(() => { 451 | // On successful completion, schedule the next update 452 | if (autoUpdate === true) { 453 | scheduleNextUpdate(); 454 | } 455 | }); 456 | } 457 | }, delayMs); 458 | }; 459 | 460 | // Only start auto-update if explicitly enabled by user 461 | if (autoUpdate === true && updateInterval > 0 && selectedSession?.capabilities.sampling) { 462 | console.log("Starting auto-update"); 463 | // Initial sample when auto-update is enabled 464 | handleSample(() => { 465 | // Schedule next update after successful initial sample 466 | if (autoUpdate === true) { 467 | scheduleNextUpdate(); 468 | } 469 | }); 470 | } 471 | 472 | // Cleanup function 473 | return () => { 474 | if (autoUpdateIntervalRef.current) { 475 | console.log("Cleaning up auto-update timer"); 476 | clearTimeout(autoUpdateIntervalRef.current); 477 | autoUpdateIntervalRef.current = null; 478 | } 479 | }; 480 | }, [autoUpdate, updateInterval, selectedSession?.id]); // Only depend on session ID, not the whole object 481 | 482 | // State for all sessions count 483 | const [totalSessions, setTotalSessions] = useState<number>(0); 484 | 485 | // Poll for active sessions 486 | useEffect(() => { 487 | const fetchSessions = async () => { 488 | try { 489 | // Fetch sessions for current user 490 | const response = await fetch(`/api/sessions?user=${encodeURIComponent(userParam)}`); 491 | if (response.ok) { 492 | const data = await response.json(); 493 | setSessions(data.sessions); 494 | 495 | // Auto-select the most recent session if none selected 496 | if (!selectedSessionId && data.sessions.length > 0) { 497 | // Sort by connection time and select the most recent 498 | const sortedSessions = [...data.sessions].sort( 499 | (a, b) => 500 | new Date(b.connectedAt).getTime() - 501 | new Date(a.connectedAt).getTime() 502 | ); 503 | setSelectedSessionId(sortedSessions[0].id); 504 | } 505 | 506 | // Clean up selected session if it's no longer available 507 | if ( 508 | selectedSessionId && 509 | !data.sessions.find((s: Session) => s.id === selectedSessionId) 510 | ) { 511 | setSelectedSessionId(null); 512 | } 513 | } 514 | 515 | // Fetch total sessions count (only if showing banner) 516 | if (showBanner) { 517 | const totalResponse = await fetch(`/api/sessions?all=true`); 518 | if (totalResponse.ok) { 519 | const totalData = await totalResponse.json(); 520 | setTotalSessions(totalData.sessions.length); 521 | } 522 | } 523 | } catch (error) { 524 | console.error("Error fetching sessions:", error); 525 | } 526 | }; 527 | 528 | // Initial fetch 529 | fetchSessions(); 530 | 531 | // Poll every 2 seconds 532 | sessionPollIntervalRef.current = setInterval(fetchSessions, 2000); 533 | 534 | return () => { 535 | if (sessionPollIntervalRef.current) { 536 | clearInterval(sessionPollIntervalRef.current); 537 | } 538 | }; 539 | }, [selectedSessionId, userParam, showBanner]); 540 | 541 | return ( 542 | <div> 543 | {showBanner && ( 544 | <> 545 | {/* Fixed position connection badge in top right corner */} 546 | <div className="fixed top-2 right-2 sm:top-4 sm:right-4 z-50 flex items-center gap-1 bg-white dark:bg-slate-800 rounded-md border px-2 py-1 shadow-lg"> 547 | <Users className="h-3 w-3 text-green-600" /> 548 | <span className="text-xs font-medium text-slate-700 dark:text-slate-300"> 549 | {sessions.length}/{totalSessions} 550 | </span> 551 | </div> 552 | 553 | {/* Main banner content */} 554 | <div className="border-b bg-slate-50 dark:bg-slate-900/50"> 555 | <div className="w-full px-3 sm:px-6 py-3"> 556 | <div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-6"> 557 | <div className="flex items-center gap-2"> 558 | <Info className="h-4 w-4 text-blue-600" /> 559 | <span className="text-sm font-medium">Connected as</span> 560 | <Badge variant="default" className="bg-blue-600 hover:bg-blue-700"> 561 | {userParam} 562 | </Badge> 563 | </div> 564 | 565 | {/* MCP URL - stacks on mobile */} 566 | <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-2 min-w-0 sm:flex-1"> 567 | <div className="flex items-center gap-2"> 568 | <Link2 className="h-4 w-4 text-blue-600 flex-shrink-0" /> 569 | <span className="text-sm font-medium flex-shrink-0">MCP URL:</span> 570 | </div> 571 | <div className="flex items-center gap-2 min-w-0 flex-1"> 572 | <code className="text-xs font-mono bg-slate-100 dark:bg-slate-900 px-2 py-1 rounded border select-all truncate flex-1 text-slate-700 dark:text-slate-300"> 573 | {config?.mcpHost || window.location.origin}/mcp{config?.mcpHostConfigured && userParam !== 'default' ? `?user=${userParam}` : ''} 574 | </code> 575 | <Button 576 | variant="outline" 577 | size="sm" 578 | onClick={() => copyToClipboard(`${config?.mcpHost || window.location.origin}/mcp${config?.mcpHostConfigured && userParam !== 'default' ? `?user=${userParam}` : ''}`)} 579 | className="h-7 px-2 flex-shrink-0" 580 | > 581 | {copied ? ( 582 | <Check className="h-3 w-3" /> 583 | ) : ( 584 | <Copy className="h-3 w-3" /> 585 | )} 586 | </Button> 587 | </div> 588 | </div> 589 | </div> 590 | 591 | {/* Helper text */} 592 | <div className="mt-2 text-xs text-muted-foreground"> 593 | Add <code className="bg-muted px-1 py-0.5 rounded font-mono text-xs">?user=YOUR_ID</code> to change user 594 | </div> 595 | </div> 596 | </div> 597 | </> 598 | )} 599 | <Card className={`w-full max-w-2xl mx-auto ${showBanner ? 'mt-4' : ''}`}> 600 | <CardHeader> 601 | <div className="relative"> 602 | <a 603 | href="https://github.com/evalstate/mcp-webcam" 604 | target="_blank" 605 | rel="noopener noreferrer" 606 | className="absolute left-0 top-0 flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors" 607 | > 608 | <Github className="h-3 w-3 sm:h-4 sm:w-4" /> 609 | <span className="hidden sm:inline">github.com/evalstate</span> 610 | </a> 611 | <CardTitle className="text-lg sm:text-xl font-bold text-center pt-6 sm:pt-0"> 612 | mcp-webcam 613 | </CardTitle> 614 | </div> 615 | <div className="w-full max-w-2xl mx-auto mt-4 space-y-2"> 616 | <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> 617 | {/* Camera selector */} 618 | <div className="space-y-2"> 619 | <label className="text-sm font-medium">Camera</label> 620 | <Select 621 | value={selectedDevice} 622 | onValueChange={setSelectedDevice} 623 | > 624 | <SelectTrigger className="w-full"> 625 | <SelectValue placeholder="Select camera" /> 626 | </SelectTrigger> 627 | <SelectContent> 628 | <SelectItem value="default">Default camera</SelectItem> 629 | {devices.map((device) => { 630 | const deviceId = 631 | device.deviceId || `device-${devices.indexOf(device)}`; 632 | return ( 633 | <SelectItem key={deviceId} value={deviceId}> 634 | {device.label || 635 | `Camera ${devices.indexOf(device) + 1}`} 636 | </SelectItem> 637 | ); 638 | })} 639 | </SelectContent> 640 | </Select> 641 | </div> 642 | 643 | {/* Session selector - always visible */} 644 | <div className="space-y-2"> 645 | <label className="text-sm font-medium"> 646 | {userParam === 'default' ? 'MCP Session' : `MCP Session (${userParam})`} 647 | </label> 648 | <Select 649 | value={selectedSessionId || ""} 650 | onValueChange={setSelectedSessionId} 651 | disabled={sessions.length === 0} 652 | > 653 | <SelectTrigger className="w-full"> 654 | <SelectValue 655 | placeholder={ 656 | sessions.length === 0 657 | ? "No connections" 658 | : "Select MCP session" 659 | } 660 | /> 661 | </SelectTrigger> 662 | <SelectContent> 663 | {sessions.length === 0 ? ( 664 | <div className="p-2 text-center text-muted-foreground text-sm"> 665 | No MCP connections available 666 | </div> 667 | ) : ( 668 | sessions.map((session) => { 669 | const connectedTime = new Date(session.connectedAt); 670 | const timeString = connectedTime.toLocaleTimeString(); 671 | 672 | // Determine color based on status 673 | let colorClass = "bg-red-500"; // Default: stale 674 | if (!session.isStale) { 675 | if (session.capabilities.sampling) { 676 | colorClass = "bg-green-500"; // Active with sampling 677 | } else { 678 | colorClass = "bg-amber-500"; // Active without sampling 679 | } 680 | } 681 | 682 | return ( 683 | <SelectItem key={session.id} value={session.id}> 684 | <div className="flex items-center gap-2"> 685 | <div 686 | className={`w-2 h-2 rounded-full ${colorClass}`} 687 | /> 688 | <span> 689 | {session.clientInfo 690 | ? `${session.clientInfo.name} v${session.clientInfo.version}` 691 | : `Session ${session.id.slice(0, 8)}`} 692 | </span> 693 | <span className="text-xs text-muted-foreground"> 694 | ({timeString}) 695 | </span> 696 | </div> 697 | </SelectItem> 698 | ); 699 | }) 700 | )} 701 | </SelectContent> 702 | </Select> 703 | </div> 704 | </div> 705 | {sessions.length > 0 && ( 706 | <div className="text-xs text-muted-foreground text-center"> 707 | <span className="inline-flex items-center gap-1"> 708 | <div className="w-2 h-2 rounded-full bg-green-500" /> Active 709 | with sampling 710 | </span> 711 | <span className="inline-flex items-center gap-1 ml-3"> 712 | <div className="w-2 h-2 rounded-full bg-amber-500" /> Active, 713 | no sampling 714 | </span> 715 | <span className="inline-flex items-center gap-1 ml-3"> 716 | <div className="w-2 h-2 rounded-full bg-red-500" /> Stale 717 | connection 718 | </span> 719 | </div> 720 | )} 721 | </div> 722 | </CardHeader> 723 | <CardContent className="px-3 sm:px-6 pt-3 pb-6"> 724 | <div className="rounded-lg overflow-hidden border border-border relative"> 725 | <Webcam 726 | ref={(webcam) => setWebcamInstance(webcam)} 727 | screenshotFormat="image/jpeg" 728 | className="w-full" 729 | videoConstraints={{ 730 | width: 1280, 731 | height: 720, 732 | ...(selectedDevice !== "default" 733 | ? { deviceId: selectedDevice } 734 | : { facingMode: "user" }), 735 | }} 736 | /> 737 | {frozenFrame && ( 738 | <img 739 | src={frozenFrame} 740 | alt="Frozen frame" 741 | className="absolute inset-0 w-full h-full object-cover" 742 | /> 743 | )} 744 | <div className="absolute top-4 right-4"> 745 | <Button 746 | onClick={toggleFreeze} 747 | variant={frozenFrame ? "destructive" : "outline"} 748 | size="sm" 749 | > 750 | {frozenFrame ? "Unfreeze" : "Freeze"} 751 | </Button> 752 | </div> 753 | </div> 754 | </CardContent> 755 | <CardFooter className="flex flex-col gap-4 pb-6"> 756 | <div className="w-full space-y-4"> 757 | {selectedSession && !selectedSession.capabilities.sampling && ( 758 | <Alert className="mb-4"> 759 | <AlertDescription> 760 | The selected MCP session does not support sampling. Please 761 | connect a client with sampling capabilities. 762 | </AlertDescription> 763 | </Alert> 764 | )} 765 | <div className="flex flex-col sm:flex-row gap-2"> 766 | <Input 767 | type="text" 768 | value={samplingPrompt} 769 | onChange={(e) => setSamplingPrompt(e.target.value)} 770 | placeholder="Enter your question..." 771 | className="flex-1" 772 | /> 773 | <Button 774 | onClick={() => handleSample()} 775 | variant="default" 776 | disabled={ 777 | isSampling || 778 | autoUpdate || 779 | !selectedSession?.capabilities.sampling 780 | } 781 | title={ 782 | !selectedSession?.capabilities.sampling 783 | ? "Selected session does not support sampling" 784 | : "" 785 | } 786 | className="w-full sm:w-auto" 787 | > 788 | {isSampling ? "Sampling..." : "Sample"} 789 | </Button> 790 | </div> 791 | 792 | 793 | {/* Sampling results display - always visible */} 794 | <div className="mt-4 min-h-[80px]"> 795 | {samplingResult && ( 796 | <Alert> 797 | <AlertTitle>Analysis Result</AlertTitle> 798 | <AlertDescription>{samplingResult}</AlertDescription> 799 | </Alert> 800 | )} 801 | 802 | {samplingError && ( 803 | <Alert variant="destructive"> 804 | <AlertTitle>Sampling Error</AlertTitle> 805 | <AlertDescription>{samplingError}</AlertDescription> 806 | </Alert> 807 | )} 808 | 809 | {!samplingResult && !samplingError && !isSampling && ( 810 | <div className="text-center text-muted-foreground text-sm p-4 border rounded-lg"> 811 | Sampling results will appear here 812 | </div> 813 | )} 814 | 815 | {isSampling && ( 816 | <div className="text-center text-muted-foreground text-sm p-4 border rounded-lg"> 817 | Processing image... 818 | </div> 819 | )} 820 | </div> 821 | 822 | {/* Auto-update and Screen Capture controls */} 823 | <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mt-4"> 824 | <div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4"> 825 | <div className="flex items-center space-x-2"> 826 | <Checkbox 827 | id="auto-update" 828 | checked={autoUpdate} 829 | onCheckedChange={(checked) => 830 | setAutoUpdate(checked as boolean) 831 | } 832 | disabled={!selectedSession?.capabilities.sampling} 833 | /> 834 | <label 835 | htmlFor="auto-update" 836 | className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 837 | > 838 | Auto-update 839 | </label> 840 | </div> 841 | <div className="flex items-center gap-2"> 842 | <Input 843 | type="number" 844 | value={updateInterval} 845 | onChange={(e) => 846 | setUpdateInterval(parseInt(e.target.value) || 30) 847 | } 848 | className="w-20" 849 | min="1" 850 | disabled={ 851 | !autoUpdate || !selectedSession?.capabilities.sampling 852 | } 853 | /> 854 | <span className="text-sm text-muted-foreground">seconds</span> 855 | </div> 856 | </div> 857 | <Button onClick={handleScreenCapture} variant="secondary" className="w-full sm:w-auto"> 858 | Test Screen Capture 859 | </Button> 860 | </div> 861 | </div> 862 | </CardFooter> 863 | </Card> 864 | </div> 865 | ); 866 | } 867 | ```