# 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
├── utils
│ └── logger.ts
├── 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"]
```
--------------------------------------------------------------------------------
/utils/logger.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Logger utility with timestamp formatting for server-side logging
3 | * All logs go to stderr to ensure compatibility with MCP communication
4 | */
5 |
6 | export class Logger {
7 | private static formatTimestamp(): string {
8 | const now = new Date();
9 | const year = now.getFullYear();
10 | const month = String(now.getMonth() + 1).padStart(2, '0');
11 | const day = String(now.getDate()).padStart(2, '0');
12 | const hours = String(now.getHours()).padStart(2, '0');
13 | const minutes = String(now.getMinutes()).padStart(2, '0');
14 | const seconds = String(now.getSeconds()).padStart(2, '0');
15 | const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
16 |
17 | return `[${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}]`;
18 | }
19 |
20 | static log(...args: any[]): void {
21 | const timestamp = this.formatTimestamp();
22 | console.error(timestamp, ...args);
23 | }
24 |
25 | static error(...args: any[]): void {
26 | const timestamp = this.formatTimestamp();
27 | console.error(timestamp, '[ERROR]', ...args);
28 | }
29 |
30 | static warn(...args: any[]): void {
31 | const timestamp = this.formatTimestamp();
32 | console.error(timestamp, '[WARN]', ...args);
33 | }
34 |
35 | static info(...args: any[]): void {
36 | const timestamp = this.formatTimestamp();
37 | console.error(timestamp, '[INFO]', ...args);
38 | }
39 |
40 | static debug(...args: any[]): void {
41 | const timestamp = this.formatTimestamp();
42 | console.error(timestamp, '[DEBUG]', ...args);
43 | }
44 | }
```
--------------------------------------------------------------------------------
/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 |
```