├── .github └── workflows │ ├── check.yml │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── AGENT.md ├── LICENSE ├── README.md ├── examples ├── chat-ui │ ├── .env.example │ ├── .gitignore │ ├── .prettierrc │ ├── AGENT.md │ ├── MODEL_SELECTOR_GUIDE.md │ ├── README.md │ ├── api │ │ └── index.ts │ ├── e2e │ │ ├── build.spec.ts │ │ └── chat.spec.ts │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── playwright.config.ts │ ├── pnpm-lock.yaml │ ├── public │ │ └── vite.svg │ ├── scripts │ │ └── update-models.ts │ ├── src │ │ ├── App.tsx │ │ ├── components │ │ │ ├── ApiKeyModal.tsx │ │ │ ├── ChatApp.tsx │ │ │ ├── ChatInput.tsx │ │ │ ├── ChatNavbar.tsx │ │ │ ├── ChatSidebar.tsx │ │ │ ├── ConversationThread.tsx │ │ │ ├── McpServerModal.tsx │ │ │ ├── McpServers.tsx │ │ │ ├── ModelSelectionModal.tsx │ │ │ ├── ModelSelector.tsx │ │ │ ├── OAuthCallback.tsx │ │ │ ├── ProviderModelsModal.tsx │ │ │ └── messages │ │ │ │ ├── AssistantMessage.tsx │ │ │ │ ├── ChatMessage.tsx │ │ │ │ ├── ErrorMessage.tsx │ │ │ │ ├── ReasoningMessage.tsx │ │ │ │ ├── ToolCallMessage.tsx │ │ │ │ └── ToolResultMessage.tsx │ │ ├── consts.ts │ │ ├── data │ │ │ └── models.json │ │ ├── hooks │ │ │ ├── useAutoscroll.ts │ │ │ ├── useConversationUpdater.ts │ │ │ ├── useIndexedDB.ts │ │ │ ├── useModels.ts │ │ │ ├── useStreamResponse.ts │ │ │ └── useTheme.ts │ │ ├── index.css │ │ ├── main.tsx │ │ ├── styles │ │ │ ├── github.css │ │ │ ├── markdown.css │ │ │ └── scrollbar.css │ │ ├── types │ │ │ ├── index.ts │ │ │ └── models.ts │ │ ├── utils │ │ │ ├── apiKeys.ts │ │ │ ├── auth.ts │ │ │ ├── debugLog.ts │ │ │ ├── modelOptions.ts │ │ │ └── modelPreferences.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── tsconfig.worker.json │ ├── vite.config.ts │ └── wrangler.jsonc ├── inspector │ ├── .gitignore │ ├── .prettierrc │ ├── AGENT.md │ ├── README.md │ ├── eslint.config.js │ ├── index.html │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ │ └── vite.svg │ ├── src │ │ ├── App.tsx │ │ ├── components │ │ │ ├── McpServers.tsx │ │ │ └── OAuthCallback.tsx │ │ ├── index.css │ │ ├── main.tsx │ │ ├── styles │ │ │ ├── github.css │ │ │ └── scrollbar.css │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── wrangler.jsonc └── servers │ ├── cf-agents │ ├── .gitignore │ ├── README.md │ ├── biome.json │ ├── package.json │ ├── pnpm-lock.yaml │ ├── src │ │ └── index.ts │ ├── tsconfig.json │ ├── worker-configuration.d.ts │ └── wrangler.jsonc │ └── hono-mcp │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ └── .gitkeep │ ├── src │ └── index.ts │ ├── tsconfig.json │ ├── worker-configuration.d.ts │ └── wrangler.jsonc ├── oranda.json ├── package.json ├── pnpm-lock.yaml ├── scripts └── pre-commit ├── src ├── auth │ ├── browser-provider.ts │ ├── callback.ts │ └── types.ts ├── index.ts ├── react │ ├── README.md │ ├── index.ts │ ├── types.ts │ └── useMcp.ts └── utils │ └── assert.ts ├── test ├── README.md ├── integration │ ├── mcp-connection.test.ts │ ├── mcp-connection.test.ts.old │ ├── server-configs.ts │ └── test-utils.ts ├── package.json ├── pnpm-lock.yaml ├── setup │ ├── global-setup.ts │ └── global-teardown.ts ├── tsconfig.json └── vitest.config.ts ├── tsconfig.json └── wrangler.jsonc /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | check: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup pnpm & install 17 | uses: wyvox/action-setup-pnpm@v3 18 | with: 19 | node-version: 22 20 | 21 | - name: Build 22 | run: pnpm build 23 | 24 | - name: Run checks 25 | run: pnpm run check 26 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup pnpm 21 | uses: pnpm/action-setup@v4 22 | 23 | - name: Setup Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: '22' 27 | cache: 'pnpm' 28 | 29 | - name: Install dependencies - root 30 | run: pnpm install 31 | 32 | - name: Install dependencies - inspector example 33 | run: pnpm install 34 | working-directory: examples/inspector 35 | 36 | - name: Install dependencies - cf-agents example 37 | run: pnpm install 38 | working-directory: examples/servers/cf-agents 39 | 40 | - name: Install dependencies - hono-mcp example 41 | run: pnpm install 42 | working-directory: examples/servers/hono-mcp 43 | 44 | - name: Install dependencies - test 45 | run: pnpm install && pnpm exec playwright install 46 | working-directory: test 47 | 48 | - name: Run tests 49 | run: pnpm test:headless 50 | working-directory: test 51 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Any Commit 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - '**' 7 | tags: 8 | - '!**' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup pnpm & install 19 | uses: wyvox/action-setup-pnpm@v3 20 | with: 21 | node-version: 22 22 | 23 | - name: Build 24 | run: pnpm build 25 | 26 | - run: pnpm dlx pkg-pr-new publish --bin 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .mcp-cli 3 | dist 4 | .env 5 | .aider* 6 | 7 | **/.claude/settings.local.json 8 | /public 9 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | ./scripts/pre-commit 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | *.md 3 | .wrangler 4 | .vscode 5 | test-results 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "singleQuote": true, 4 | "semi": false, 5 | "useTabs": false, 6 | "overrides": [ 7 | { 8 | "files": ["*.jsonc"], 9 | "options": { 10 | "trailingComma": "none" 11 | } 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /AGENT.md: -------------------------------------------------------------------------------- 1 | # use-mcp Project Guidelines 2 | 3 | ## Build, Lint, and Test Commands 4 | - `pnpm dev`: Run development build with watch mode and start all examples/servers 5 | - Chat UI: http://localhost:5002 6 | - Inspector: http://localhost:5001 7 | - Hono MCP Server: http://localhost:5101 8 | - CF Agents MCP Server: http://localhost:5102 9 | - `pnpm build`: Build the project 10 | - `pnpm check`: Run prettier checks and TypeScript type checking 11 | 12 | ### Integration Tests (in /test directory) 13 | - `cd test && pnpm test`: Run integration tests headlessly (default) 14 | - `cd test && pnpm test:headed`: Run integration tests with visible browser 15 | - `cd test && pnpm test:headless`: Run integration tests headlessly 16 | - `cd test && pnpm test:watch`: Run integration tests in watch mode 17 | - `cd test && pnpm test:ui`: Run integration tests with interactive UI 18 | 19 | ## Code Style Guidelines 20 | 21 | ### Imports 22 | - Use explicit .js extensions in imports (ES modules style) 23 | - Group imports: SDK imports first, followed by React/external deps, then local imports 24 | 25 | ### Formatting 26 | - Single quotes for strings 27 | - No semicolons at line ends 28 | - 140 character line width 29 | - Use 2 space indentation 30 | 31 | ### Types and Naming 32 | - Strong typing with TypeScript 33 | - Descriptive interface names with camelCase for variables/functions and PascalCase for types 34 | - Comprehensive JSDoc comments for public API functions and types 35 | 36 | ### Error Handling 37 | - Use assertions with descriptive messages 38 | - Log errors with appropriate levels (debug, info, warn, error) 39 | - Defensive error handling with specific error types when available 40 | 41 | ## Development Workflow 42 | - **Commit changes frequently** after each logical change or debugging step 43 | - Use descriptive commit messages that explain what was changed and why 44 | 45 | ### React Patterns 46 | - Use React hooks with useRef for mutable values 47 | - Stable callbacks with useCallback and appropriate dependencies -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Cloudflare, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/chat-ui/.env.example: -------------------------------------------------------------------------------- 1 | # OpenRouter OAuth Configuration 2 | # Note: OpenRouter OAuth doesn't require client ID, only callback URL configuration 3 | VITE_OPENROUTER_REDIRECT_URI=http://localhost:5002/oauth/openrouter/callback 4 | -------------------------------------------------------------------------------- /examples/chat-ui/.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 | 26 | .wrangler 27 | 28 | # Playwright test results 29 | test-results/ 30 | playwright-report/ -------------------------------------------------------------------------------- /examples/chat-ui/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "printWidth": 140 7 | } 8 | -------------------------------------------------------------------------------- /examples/chat-ui/AGENT.md: -------------------------------------------------------------------------------- 1 | # AI Chat Template - Development Guide 2 | 3 | ## Commands 4 | - **Dev server**: `pnpm dev` 5 | - **Build**: `pnpm build` (runs TypeScript compilation then Vite build) 6 | - **Lint**: `pnpm lint` (ESLint) 7 | - **Deploy**: `pnpm deploy` (builds and deploys with Wrangler) 8 | - **Test**: `pnpm test` (Playwright E2E tests) 9 | - **Test UI**: `pnpm test:ui` (Playwright test runner with UI) 10 | - **Test headed**: `pnpm test:headed` (Run tests in visible browser) 11 | 12 | ## Code Style 13 | - **Formatting**: Prettier with 2-space tabs, single quotes, no semicolons, 140 char line width 14 | - **Imports**: Use `.tsx`/`.ts` extensions in imports, group by external/internal 15 | - **Components**: React FC with explicit typing, PascalCase names 16 | - **Hooks**: Custom hooks start with `use`, camelCase 17 | - **Types**: Define interfaces in `src/types/index.ts`, use `type` for unions 18 | - **Files**: Use PascalCase for components, camelCase for hooks/utilities 19 | - **State**: Use proper TypeScript typing for all state variables 20 | - **Error handling**: Use try/catch blocks with proper error propagation 21 | - **Database**: IndexedDB with typed interfaces, async/await pattern 22 | - **Styling**: Tailwind CSS classes, responsive design patterns 23 | 24 | ## Tech Stack 25 | React 19, TypeScript, Vite, Tailwind CSS, Hono API, Cloudflare Workers, IndexedDB, React Router, use-mcp 26 | 27 | ## Chat UI Specific Guidelines 28 | 29 | ### Background Animation 30 | - Use CSS `hue-rotate` filter for color cycling (more performant than gradient animation) 31 | - Isolate animations to `::before` pseudo-elements with `z-index: -1` to prevent inheritance 32 | - Use `background-size: 100% 100vh` and `background-repeat: repeat-y` for repeating patterns 33 | - Initialize with random animation delay using CSS custom properties 34 | 35 | ### Modal Patterns 36 | - Use `rgba(0,0,0,0.8)` for modal backdrops (avoid gradients that cause banding) 37 | - Implement click-outside-to-dismiss with `onClick` on backdrop and `stopPropagation` on content 38 | - Add `cursor-pointer` to all clickable elements including close buttons 39 | - Use `document.body.style.overflow = 'hidden'` to prevent background scrolling 40 | 41 | ### MCP Server Management 42 | - Store multiple servers in localStorage as JSON array (`mcpServers`) 43 | - Track tool counts separately (`mcpServerToolCounts`) for disabled servers 44 | - Use server-specific IDs for state management 45 | - Each MCP server gets unique `useMcp` hook instance for proper isolation 46 | - OAuth state parameter automatically handles multiple server differentiation 47 | 48 | ### State Persistence 49 | - Use localStorage for user preferences (model selection, server configurations) 50 | - Use sessionStorage for temporary state (single server URL in legacy components) 51 | - Clear related data when removing servers or models 52 | 53 | ### UI Indicators 54 | - Use emoji-based indicators (🧠 for models, 🔌 for MCP servers) 55 | - Format counts as `enabled/total` (e.g., "1/2 servers, 3/5 tools") 56 | - Place model selector on left, MCP servers on right for balance 57 | - Show "none" instead of red symbols for cleaner unconfigured states 58 | 59 | ### Component Organization 60 | - Keep disabled components in React tree but hidden with CSS (`{false && ...}`) 61 | - This prevents MCP connections from being destroyed when modals close 62 | - Use conditional rendering sparingly, prefer CSS visibility for stateful components 63 | 64 | ### Routing and OAuth 65 | - Use React Router for OAuth callback routes (`/oauth/callback`) 66 | - OAuth callback should match main app styling with loading indicators 67 | - use-mcp library handles multiple server OAuth flows automatically via state parameters 68 | 69 | ### Performance Considerations 70 | - Avoid animating gradients directly (causes repainting) 71 | - Use transform and filter animations (hardware accelerated) 72 | - Aggregate tools from multiple sources efficiently 73 | - Minimize localStorage reads in render loops 74 | -------------------------------------------------------------------------------- /examples/chat-ui/MODEL_SELECTOR_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Model Selector Guide 2 | 3 | This guide explains how to use the new favorites-based model selector system. 4 | 5 | ## Overview 6 | 7 | The chat UI now supports: 8 | - **39 models** from 3 providers (Anthropic, Groq, OpenRouter) 9 | - **Favorites system** - star your preferred models 10 | - **Search functionality** - find models by name or provider 11 | - **Tool filtering** - show only models that support tool calling 12 | - **OAuth authentication** - seamless OpenRouter integration 13 | 14 | ## Features 15 | 16 | ### Model Selection 17 | - Click the model selector to view all available models 18 | - Models are loaded from the live models.dev API 19 | - Each model shows provider logo, name, and capabilities 20 | 21 | ### Favorites System 22 | - ⭐ Star models to add them to your favorites 23 | - Starred models are saved to local storage 24 | - Use the "Favorites" filter to show only starred models 25 | 26 | ### Search & Filtering 27 | - 🔍 Search box to find models by name or provider 28 | - 🔧 "Tools Only" filter (appears when MCP tools are available) 29 | - ⭐ "Favorites" filter to show only starred models 30 | 31 | ### Authentication 32 | - **API Keys**: Enter manually for Anthropic and Groq 33 | - **OAuth**: One-click authentication for OpenRouter 34 | - Authentication status shown with icons (✓ or ⚠️) 35 | 36 | ## Provider Support 37 | 38 | ### Anthropic 39 | - **Models**: 9 models including Claude 4 Sonnet 40 | - **Auth**: API key (requires manual entry) 41 | - **Tools**: All models support tool calling 42 | 43 | ### Groq 44 | - **Models**: 13 models including Llama and Qwen variants 45 | - **Auth**: API key (requires manual entry) 46 | - **Tools**: 11 models support tool calling 47 | 48 | ### OpenRouter 49 | - **Models**: 17 models from various providers 50 | - **Auth**: OAuth PKCE flow (one-click authentication) 51 | - **Tools**: All models support tool calling 52 | 53 | ## Setting Up OpenRouter OAuth 54 | 55 | 1. Create an OpenRouter account at https://openrouter.ai 56 | 2. Go to your dashboard and create an OAuth app 57 | 3. Set the redirect URI to: `http://localhost:5002/oauth/openrouter/callback` 58 | 4. Copy your client ID to your `.env` file: 59 | ``` 60 | VITE_OPENROUTER_CLIENT_ID=your_client_id_here 61 | ``` 62 | 63 | ## Usage Tips 64 | 65 | 1. **Star your favorites** - This makes model selection much faster 66 | 2. **Use search** - With 39 models, search helps find what you need 67 | 3. **Filter by tools** - When using MCP tools, enable "Tools Only" filter 68 | 4. **Try different providers** - Each has unique models with different strengths 69 | 70 | ## Model Data Updates 71 | 72 | Model data is fetched from models.dev API and can be updated with: 73 | 74 | ```bash 75 | pnpm update-models 76 | ``` 77 | 78 | This will refresh the model list with the latest information from the API. 79 | 80 | ## Local Storage 81 | 82 | The following preferences are saved locally: 83 | - **Favorites**: `aiChatTemplate_favorites_v1` 84 | - **Tokens**: `aiChatTemplate_token_[provider]` 85 | - **Selected Model**: `aiChatTemplate_selectedModel` 86 | 87 | ## Troubleshooting 88 | 89 | - **OAuth popup blocked**: Allow popups for this site 90 | - **Authentication failed**: Check your API keys or re-authenticate 91 | - **Model not working**: Verify the model supports the features you're using 92 | - **Tools not showing**: Enable "Tools Only" filter when MCP tools are configured 93 | -------------------------------------------------------------------------------- /examples/chat-ui/README.md: -------------------------------------------------------------------------------- 1 | # AI Chat with MCP 2 | 3 | A React-based AI chat application demonstrating [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) integration with multiple AI providers. 4 | 5 | This static web application showcases how to use the [`use-mcp`](../../) library to connect to MCP servers, providing extensible AI capabilities through external tools and services. The app supports multiple AI models, stores conversations locally in IndexedDB, and includes OAuth authentication for MCP server connections. 6 | 7 | **Live demo**: [chat.use-mcp.dev](https://chat.use-mcp.dev) 8 | 9 | ## Features 10 | 11 | - **MCP Integration**: Connect to MCP servers with OAuth authentication support 12 | - **Multi-model Support**: Anthropic (Claude) and Groq (Llama) models with API key authentication 13 | - **Local Storage**: Conversations stored in browser's IndexedDB 14 | - **Static Deployment**: Builds to static assets for deployment anywhere 15 | - **Modern Stack**: React 19, TypeScript, Tailwind CSS, Vite 16 | 17 | ## Get started 18 | 19 | ```sh 20 | pnpm install 21 | pnpm dev 22 | ``` 23 | 24 | Build and deploy: 25 | 26 | ```sh 27 | pnpm build 28 | pnpm run deploy # deploys to Cloudflare Pages 29 | ``` 30 | 31 | ## Development 32 | 33 | - **Dev server**: `pnpm dev` (runs on port 5002) 34 | - **Build**: `pnpm build` 35 | - **Lint**: `pnpm lint` 36 | - **Test**: `pnpm test` (Playwright E2E tests) 37 | -------------------------------------------------------------------------------- /examples/chat-ui/api/index.ts: -------------------------------------------------------------------------------- 1 | import type { LanguageModelV1StreamPart } from 'ai' 2 | import { streamText, extractReasoningMiddleware, wrapLanguageModel } from 'ai' 3 | import { createWorkersAI } from 'workers-ai-provider' 4 | import { Hono } from 'hono' 5 | 6 | interface Env { 7 | ASSETS: Fetcher 8 | AI: Ai 9 | } 10 | 11 | type message = { 12 | role: 'system' | 'user' | 'assistant' | 'data' 13 | content: string 14 | } 15 | 16 | const app = new Hono<{ Bindings: Env }>() 17 | 18 | // Handle the /api/chat endpoint 19 | app.post('/api/chat', async (c) => { 20 | try { 21 | const { messages, reasoning }: { messages: message[]; reasoning: boolean } = await c.req.json() 22 | 23 | const workersai = createWorkersAI({ binding: c.env.AI }) 24 | 25 | // Choose model based on reasoning preference 26 | const model = reasoning 27 | ? wrapLanguageModel({ 28 | model: workersai('@cf/deepseek-ai/deepseek-r1-distill-qwen-32b'), 29 | middleware: [ 30 | extractReasoningMiddleware({ tagName: 'think' }), 31 | //custom middleware to inject <think> tag at the beginning of a reasoning if it is missing 32 | { 33 | wrapGenerate: async ({ doGenerate }) => { 34 | const result = await doGenerate() 35 | 36 | if (!result.text?.includes('<think>')) { 37 | result.text = `<think>${result.text}` 38 | } 39 | 40 | return result 41 | }, 42 | wrapStream: async ({ doStream }) => { 43 | const { stream, ...rest } = await doStream() 44 | 45 | let generatedText = '' 46 | const transformStream = new TransformStream<LanguageModelV1StreamPart, LanguageModelV1StreamPart>({ 47 | transform(chunk, controller) { 48 | //we are manually adding the <think> tag because some times, distills of reasoning models omit it 49 | if (chunk.type === 'text-delta') { 50 | if (!generatedText.includes('<think>')) { 51 | generatedText += '<think>' 52 | controller.enqueue({ 53 | type: 'text-delta', 54 | textDelta: '<think>', 55 | }) 56 | } 57 | generatedText += chunk.textDelta 58 | } 59 | 60 | controller.enqueue(chunk) 61 | }, 62 | }) 63 | 64 | return { 65 | stream: stream.pipeThrough(transformStream), 66 | ...rest, 67 | } 68 | }, 69 | }, 70 | ], 71 | }) 72 | : workersai('@cf/meta/llama-3.3-70b-instruct-fp8-fast') 73 | 74 | const systemPrompt: message = { 75 | role: 'system', 76 | content: ` 77 | - Do not wrap your responses in html tags. 78 | - Do not apply any formatting to your responses. 79 | - You are an expert conversational chatbot. Your objective is to be as helpful as possible. 80 | - You must keep your responses relevant to the user's prompt. 81 | - You must respond with a maximum of 512 tokens (300 words). 82 | - You must respond clearly and concisely, and explain your logic if required. 83 | - You must not provide any personal information. 84 | - Do not respond with your own personal opinions, and avoid topics unrelated to the user's prompt. 85 | ${ 86 | messages.length <= 1 && 87 | `- Important REMINDER: You MUST provide a 5 word title at the END of your response using <chat-title> </chat-title> tags. 88 | If you do not do this, this session will error. 89 | For example, <chat-title>Hello and Welcome</chat-title> Hi, how can I help you today? 90 | ` 91 | } 92 | `, 93 | } 94 | 95 | const text = await streamText({ 96 | model, 97 | messages: [systemPrompt, ...messages], 98 | maxTokens: 2048, 99 | maxRetries: 3, 100 | }) 101 | 102 | return text.toDataStreamResponse({ 103 | sendReasoning: true, 104 | }) 105 | } catch (error) { 106 | return c.json({ error: `Chat completion failed. ${(error as Error)?.message}` }, 500) 107 | } 108 | }) 109 | 110 | // Handle static assets and fallback routes 111 | app.all('*', async (c) => { 112 | if (c.env.ASSETS) { 113 | return c.env.ASSETS.fetch(c.req.raw) 114 | } 115 | return c.text('Not found', 404) 116 | }) 117 | 118 | export default app 119 | -------------------------------------------------------------------------------- /examples/chat-ui/e2e/build.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | import { exec } from 'child_process' 3 | import { promisify } from 'util' 4 | 5 | const execAsync = promisify(exec) 6 | 7 | interface ExecError extends Error { 8 | stdout?: string 9 | stderr?: string 10 | } 11 | 12 | test.describe('Build Tests', () => { 13 | test('should build without type errors', async () => { 14 | try { 15 | const { stderr } = await execAsync('pnpm build', { 16 | cwd: process.cwd(), 17 | timeout: 60000, 18 | }) 19 | 20 | // Check for TypeScript compilation errors 21 | expect(stderr).not.toContain('error TS') 22 | expect(stderr).not.toContain('Type error') 23 | } catch (error) { 24 | const execError = error as ExecError 25 | console.error('Build failed:', execError.stdout, execError.stderr) 26 | throw new Error(`Build failed: ${execError.message}`) 27 | } 28 | }) 29 | 30 | test('should lint without errors', async () => { 31 | try { 32 | const { stderr } = await execAsync('pnpm lint', { 33 | cwd: process.cwd(), 34 | timeout: 30000, 35 | }) 36 | 37 | // ESLint should pass without errors 38 | expect(stderr).not.toContain('error') 39 | } catch (error) { 40 | const execError = error as ExecError 41 | console.error('Lint failed:', execError.stdout, execError.stderr) 42 | throw new Error(`Lint failed: ${execError.message}`) 43 | } 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /examples/chat-ui/e2e/chat.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | 3 | test.describe('Chat Functionality', () => { 4 | test.beforeEach(async ({ page }) => { 5 | // Mock the external AI API calls 6 | await page.route('https://api.groq.com/**', async (route) => { 7 | // Mock streaming response for Groq 8 | const mockResponse = 'Hello! This is a mocked response from the AI assistant.' 9 | 10 | route.fulfill({ 11 | status: 200, 12 | headers: { 13 | 'Content-Type': 'text/plain', 14 | }, 15 | body: mockResponse, 16 | }) 17 | }) 18 | 19 | await page.route('https://api.anthropic.com/**', async (route) => { 20 | // Mock streaming response for Anthropic 21 | const mockResponse = 'Hello! This is a mocked response from Claude.' 22 | 23 | route.fulfill({ 24 | status: 200, 25 | headers: { 26 | 'Content-Type': 'text/plain', 27 | }, 28 | body: mockResponse, 29 | }) 30 | }) 31 | 32 | // Mock the local API endpoint 33 | await page.route('/api/chat', async (route) => { 34 | const mockResponse = { 35 | choices: [ 36 | { 37 | delta: { 38 | content: 'Hello! This is a mocked response from the local API.', 39 | }, 40 | }, 41 | ], 42 | } 43 | 44 | route.fulfill({ 45 | status: 200, 46 | headers: { 47 | 'Content-Type': 'application/json', 48 | }, 49 | body: JSON.stringify(mockResponse), 50 | }) 51 | }) 52 | }) 53 | 54 | test('should load the chat interface', async ({ page }) => { 55 | await page.goto('/') 56 | 57 | // Check if the main elements are present 58 | await expect(page.locator('body')).toBeVisible() 59 | 60 | // Look for chat-related elements (adjust selectors based on your UI) 61 | const messageInput = page.locator('textarea, input[type="text"]').first() 62 | await expect(messageInput).toBeVisible() 63 | 64 | // Check for a send button or similar 65 | const sendButton = page 66 | .locator('button') 67 | .filter({ hasText: /send|submit/i }) 68 | .first() 69 | if (await sendButton.isVisible()) { 70 | await expect(sendButton).toBeVisible() 71 | } 72 | }) 73 | 74 | test('should send a message and receive a response', async ({ page }) => { 75 | await page.goto('/') 76 | 77 | // Wait for the page to load 78 | await page.waitForLoadState('networkidle') 79 | 80 | // Find the message input field 81 | const messageInput = page.locator('textarea, input[type="text"]').first() 82 | await expect(messageInput).toBeVisible() 83 | 84 | // Type a test message 85 | const testMessage = 'Hello, can you help me?' 86 | await messageInput.fill(testMessage) 87 | await expect(messageInput).toHaveValue(testMessage) 88 | 89 | // Send the message by pressing Enter (most common method) 90 | await messageInput.press('Enter') 91 | 92 | // Wait a moment for the form submission to process 93 | await page.waitForTimeout(1000) 94 | 95 | // Check that a conversation has started - look for any text content that might be a message 96 | const pageContent = await page.textContent('body') 97 | expect(pageContent).toContain(testMessage) 98 | 99 | // The input may or may not clear depending on implementation details 100 | // This is less critical than the message appearing 101 | }) 102 | 103 | test('should handle API errors gracefully', async ({ page }) => { 104 | // Override mocks to simulate API errors 105 | await page.route('https://api.groq.com/**', async (route) => { 106 | route.fulfill({ 107 | status: 500, 108 | headers: { 109 | 'Content-Type': 'application/json', 110 | }, 111 | body: JSON.stringify({ error: 'Internal server error' }), 112 | }) 113 | }) 114 | 115 | await page.route('https://api.anthropic.com/**', async (route) => { 116 | route.fulfill({ 117 | status: 401, 118 | headers: { 119 | 'Content-Type': 'application/json', 120 | }, 121 | body: JSON.stringify({ error: 'Unauthorized' }), 122 | }) 123 | }) 124 | 125 | await page.goto('/') 126 | 127 | // Try to send a message 128 | const messageInput = page.locator('textarea, input[type="text"]').first() 129 | await messageInput.fill('Test message') 130 | 131 | const sendButton = page 132 | .locator('button') 133 | .filter({ hasText: /send|submit/i }) 134 | .first() 135 | if (await sendButton.isVisible()) { 136 | await sendButton.click() 137 | } else { 138 | await messageInput.press('Enter') 139 | } 140 | 141 | // Should handle the error without crashing 142 | await page.waitForTimeout(2000) 143 | 144 | // The app should still be functional (not showing a white screen or error page) 145 | await expect(page.locator('body')).toBeVisible() 146 | }) 147 | 148 | test('should persist conversations', async ({ page }) => { 149 | await page.goto('/') 150 | 151 | // Send a message 152 | const messageInput = page.locator('textarea, input[type="text"]').first() 153 | await messageInput.fill('Test message for persistence') 154 | 155 | const sendButton = page 156 | .locator('button') 157 | .filter({ hasText: /send|submit/i }) 158 | .first() 159 | if (await sendButton.isVisible()) { 160 | await sendButton.click() 161 | } else { 162 | await messageInput.press('Enter') 163 | } 164 | 165 | await page.waitForTimeout(2000) 166 | 167 | // Refresh the page 168 | await page.reload() 169 | await page.waitForLoadState('networkidle') 170 | 171 | // Check if the conversation persisted (this depends on your IndexedDB implementation) 172 | // We'll just verify the page loads correctly after refresh 173 | await expect(page.locator('body')).toBeVisible() 174 | 175 | const inputAfterReload = page.locator('textarea, input[type="text"]').first() 176 | await expect(inputAfterReload).toBeVisible() 177 | }) 178 | }) 179 | -------------------------------------------------------------------------------- /examples/chat-ui/eslint.config.js: -------------------------------------------------------------------------------- 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.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 23 | }, 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /examples/chat-ui/index.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>use-mcp chat</title> 8 | </head> 9 | <body> 10 | <div id="root"></div> 11 | <script type="module" src="/src/main.tsx"></script> 12 | </body> 13 | </html> 14 | -------------------------------------------------------------------------------- /examples/chat-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ai-chat-template", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --port=5002", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "deploy": "npm run build && npm exec wrangler deploy", 12 | "test": "playwright test", 13 | "test:ui": "playwright test --ui", 14 | "test:headed": "playwright test --headed", 15 | "test:html": "playwright test --reporter=html", 16 | "update-models": "tsx scripts/update-models.ts" 17 | }, 18 | "dependencies": { 19 | "@ai-sdk/anthropic": "^1.2.12", 20 | "@ai-sdk/groq": "^1.2.9", 21 | "@ai-sdk/openai": "^1.3.23", 22 | "@tailwindcss/typography": "^0.5.16", 23 | "@tailwindcss/vite": "^4.0.14", 24 | "ai": "^4.1.61", 25 | "hono": "^4.7.9", 26 | "idb": "^8.0.2", 27 | "lucide-react": "^0.482.0", 28 | "react": "^19.0.0", 29 | "react-dom": "^19.0.0", 30 | "react-markdown": "^10.1.0", 31 | "react-router-dom": "^7.6.2", 32 | "rehype-highlight": "^7.0.2", 33 | "remark-gfm": "^4.0.1", 34 | "tailwindcss": "^4.0.14", 35 | "workers-ai-provider": "^0.1.3" 36 | }, 37 | "devDependencies": { 38 | "@ai-sdk/ui-utils": "^1.2.11", 39 | "@cloudflare/vite-plugin": "^0.1.12", 40 | "@cloudflare/workers-types": "^4.20250317.0", 41 | "@eslint/js": "^9.21.0", 42 | "@playwright/test": "^1.52.0", 43 | "@types/react": "^19.0.10", 44 | "@types/react-dom": "^19.0.4", 45 | "@vitejs/plugin-react": "^4.3.4", 46 | "eslint": "^9.21.0", 47 | "eslint-plugin-react-hooks": "^5.1.0", 48 | "eslint-plugin-react-refresh": "^0.4.19", 49 | "globals": "^15.15.0", 50 | "prettier": "^3.5.3", 51 | "tsx": "^4.20.3", 52 | "typescript": "~5.7.2", 53 | "typescript-eslint": "^8.24.1", 54 | "use-mcp": "link:../..", 55 | "vite": "^6.2.0", 56 | "wrangler": "^4.1.0" 57 | }, 58 | "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" 59 | } 60 | -------------------------------------------------------------------------------- /examples/chat-ui/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | 3 | /** 4 | * @see https://playwright.dev/docs/test-configuration 5 | */ 6 | export default defineConfig({ 7 | testDir: './e2e', 8 | /* Run tests in files in parallel */ 9 | fullyParallel: true, 10 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 11 | forbidOnly: !!process.env.CI, 12 | /* Retry on CI only */ 13 | retries: process.env.CI ? 2 : 0, 14 | /* Opt out of parallel tests on CI. */ 15 | workers: process.env.CI ? 1 : undefined, 16 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 17 | reporter: 'list', 18 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 19 | use: { 20 | /* Base URL to use in actions like `await page.goto('/')`. */ 21 | baseURL: 'http://localhost:5173', 22 | 23 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 24 | trace: 'on-first-retry', 25 | }, 26 | 27 | /* Configure projects for major browsers */ 28 | projects: [ 29 | { 30 | name: 'chromium', 31 | use: { ...devices['Desktop Chrome'] }, 32 | }, 33 | ], 34 | 35 | /* Run your local dev server before starting the tests */ 36 | webServer: { 37 | command: 'pnpm dev', 38 | url: 'http://localhost:5173', 39 | reuseExistingServer: !process.env.CI, 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /examples/chat-ui/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> -------------------------------------------------------------------------------- /examples/chat-ui/scripts/update-models.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env tsx 2 | 3 | import { writeFileSync } from 'fs' 4 | import { join } from 'path' 5 | 6 | interface ModelData { 7 | id: string 8 | name: string 9 | attachment: boolean 10 | reasoning: boolean 11 | temperature: boolean 12 | tool_call: boolean 13 | knowledge: string 14 | release_date: string 15 | last_updated: string 16 | modalities: { 17 | input: string[] 18 | output: string[] 19 | } 20 | open_weights: boolean 21 | limit: { 22 | context: number 23 | output: number 24 | } 25 | cost?: { 26 | input: number 27 | output: number 28 | cache_read?: number 29 | cache_write?: number 30 | } 31 | } 32 | 33 | interface ProviderData { 34 | models: Record<string, ModelData> 35 | } 36 | 37 | interface ModelsDevData { 38 | anthropic: ProviderData 39 | groq: ProviderData 40 | openrouter: ProviderData 41 | [key: string]: ProviderData 42 | } 43 | 44 | const SUPPORTED_PROVIDERS = ['anthropic', 'groq', 'openrouter'] as const 45 | type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number] 46 | 47 | async function fetchModelsData(): Promise<ModelsDevData> { 48 | console.log('Fetching models data from models.dev...') 49 | 50 | const response = await fetch('https://models.dev/api.json') 51 | if (!response.ok) { 52 | throw new Error(`Failed to fetch models data: ${response.status} ${response.statusText}`) 53 | } 54 | 55 | return await response.json() 56 | } 57 | 58 | function filterAndTransformModels(data: ModelsDevData) { 59 | const filtered: Record<SupportedProvider, Record<string, ModelData>> = { 60 | anthropic: {}, 61 | groq: {}, 62 | openrouter: {}, 63 | } 64 | 65 | // Filter by supported providers 66 | for (const provider of SUPPORTED_PROVIDERS) { 67 | if (data[provider] && data[provider].models) { 68 | filtered[provider] = data[provider].models 69 | } 70 | } 71 | 72 | return filtered 73 | } 74 | 75 | async function main() { 76 | try { 77 | const data = await fetchModelsData() 78 | const filteredData = filterAndTransformModels(data) 79 | 80 | const outputPath = join(process.cwd(), 'src', 'data', 'models.json') 81 | writeFileSync(outputPath, JSON.stringify(filteredData, null, 2)) 82 | 83 | console.log(`✅ Models data updated successfully at ${outputPath}`) 84 | 85 | // Print summary 86 | let totalModels = 0 87 | let toolSupportingModels = 0 88 | 89 | for (const [provider, models] of Object.entries(filteredData)) { 90 | const modelCount = Object.keys(models).length 91 | const toolModels = Object.values(models).filter((m) => m.tool_call).length 92 | 93 | console.log(` ${provider}: ${modelCount} models (${toolModels} support tools)`) 94 | totalModels += modelCount 95 | toolSupportingModels += toolModels 96 | } 97 | 98 | console.log(`\nTotal: ${totalModels} models (${toolSupportingModels} support tools)`) 99 | } catch (error) { 100 | console.error('❌ Failed to update models data:', error) 101 | process.exit(1) 102 | } 103 | } 104 | 105 | main() 106 | -------------------------------------------------------------------------------- /examples/chat-ui/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' 2 | import ChatApp from './components/ChatApp' 3 | import OAuthCallback from './components/OAuthCallback' 4 | 5 | function App() { 6 | return ( 7 | <Router> 8 | <Routes> 9 | <Route path="/oauth/openrouter/callback" element={<OAuthCallback provider="openrouter" />} /> 10 | <Route path="/oauth/callback" element={<OAuthCallback provider="openrouter" />} /> 11 | <Route path="/" element={<ChatApp />} /> 12 | </Routes> 13 | </Router> 14 | ) 15 | } 16 | 17 | export default App 18 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/ApiKeyModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { createPortal } from 'react-dom' 3 | import { X, Eye, EyeOff } from 'lucide-react' 4 | import { type Provider } from '../types/models' 5 | 6 | interface ApiKeyModalProps { 7 | isOpen: boolean 8 | onClose: () => void 9 | provider: Provider 10 | onSave: (apiKey: string) => void 11 | } 12 | 13 | const ApiKeyModal: React.FC<ApiKeyModalProps> = ({ isOpen, onClose, provider, onSave }) => { 14 | const [apiKey, setApiKey] = useState('') 15 | const [showApiKey, setShowApiKey] = useState(false) 16 | 17 | if (!isOpen) return null 18 | 19 | const handleSubmit = (e: React.FormEvent) => { 20 | e.preventDefault() 21 | if (apiKey.trim()) { 22 | onSave(apiKey.trim()) 23 | setApiKey('') 24 | onClose() 25 | } 26 | } 27 | 28 | const handleClose = () => { 29 | setApiKey('') 30 | onClose() 31 | } 32 | 33 | const modalContent = ( 34 | <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={handleClose}> 35 | <div className="bg-white rounded-lg p-6 w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}> 36 | <div className="flex items-center justify-between mb-4"> 37 | <h2 className="text-lg font-semibold text-zinc-900"> 38 | {provider.logo} {provider.name} API Key 39 | </h2> 40 | <button onClick={handleClose} className="text-zinc-400 hover:text-zinc-600 p-1"> 41 | <X size={20} /> 42 | </button> 43 | </div> 44 | 45 | <p className="text-sm text-zinc-600 mb-4"> 46 | Enter your {provider.name} API key to use this model. Your key will be stored locally in your browser. 47 | </p> 48 | 49 | <form onSubmit={handleSubmit}> 50 | <div className="mb-4"> 51 | <label htmlFor="apiKey" className="block text-sm font-medium text-zinc-700 mb-2"> 52 | API Key 53 | </label> 54 | <div className="relative"> 55 | <input 56 | type={showApiKey ? 'text' : 'password'} 57 | id="apiKey" 58 | value={apiKey} 59 | onChange={(e) => setApiKey(e.target.value)} 60 | className="w-full px-3 py-2 border border-zinc-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent pr-10" 61 | placeholder={`Enter your ${provider.name} API key`} 62 | autoComplete="off" 63 | /> 64 | <button 65 | type="button" 66 | onClick={() => setShowApiKey(!showApiKey)} 67 | className="absolute inset-y-0 right-0 pr-3 flex items-center text-zinc-400 hover:text-zinc-600" 68 | > 69 | {showApiKey ? <EyeOff size={16} /> : <Eye size={16} />} 70 | </button> 71 | </div> 72 | </div> 73 | 74 | <div className="flex items-center justify-between"> 75 | <a 76 | href={provider.documentationUrl} 77 | target="_blank" 78 | rel="noopener noreferrer" 79 | className="text-sm text-blue-600 hover:text-blue-800" 80 | > 81 | Get API key → 82 | </a> 83 | <div className="flex gap-2"> 84 | <button type="button" onClick={handleClose} className="px-4 py-2 text-sm text-zinc-600 hover:text-zinc-800"> 85 | Cancel 86 | </button> 87 | <button 88 | type="submit" 89 | disabled={!apiKey.trim()} 90 | className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-zinc-300 disabled:cursor-not-allowed" 91 | > 92 | Save 93 | </button> 94 | </div> 95 | </div> 96 | </form> 97 | </div> 98 | </div> 99 | ) 100 | 101 | return createPortal(modalContent, document.body) 102 | } 103 | 104 | export default ApiKeyModal 105 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/ChatApp.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import ConversationThread from './ConversationThread.tsx' 3 | import ChatSidebar from './ChatSidebar' 4 | import ChatNavbar from './ChatNavbar' 5 | import { storeName } from '../consts.ts' 6 | import { type Conversation } from '../types' 7 | import { useIndexedDB } from '../hooks/useIndexedDB' 8 | import { type Model } from '../types/models' 9 | import { getSelectedModel, setSelectedModel as saveSelectedModel } from '../utils/modelPreferences' 10 | import { useModels } from '../hooks/useModels' 11 | import { type IDBPDatabase } from 'idb' 12 | import { type Tool } from 'use-mcp/react' 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 15 | interface ChatAppProps {} 16 | 17 | const ChatApp: React.FC<ChatAppProps> = () => { 18 | const [conversations, setConversations] = useState<Conversation[]>([]) 19 | const [conversationId, setConversationId] = useState<number | undefined>(undefined) 20 | const [sidebarVisible, setSidebarVisible] = useState(false) 21 | 22 | const [selectedModel, setSelectedModel] = useState<Model>(getSelectedModel()) 23 | const [apiKeyUpdateTrigger, setApiKeyUpdateTrigger] = useState<number>(0) 24 | const [mcpTools, setMcpTools] = useState<Tool[]>([]) 25 | const [animationDelay] = useState<number>(() => -Math.random() * 60) 26 | const db = useIndexedDB() 27 | const { models, addToFavorites, toggleFavorite, isFavorite, getFavoriteModels } = useModels() 28 | 29 | const handleApiKeyUpdate = () => { 30 | setApiKeyUpdateTrigger((prev) => prev + 1) 31 | } 32 | 33 | // Handle OAuth success messages from popups 34 | useEffect(() => { 35 | const handleMessage = (event: MessageEvent) => { 36 | if (event.data.type === 'oauth_success') { 37 | handleApiKeyUpdate() 38 | } 39 | } 40 | 41 | window.addEventListener('message', handleMessage) 42 | return () => window.removeEventListener('message', handleMessage) 43 | }, []) 44 | 45 | const handleModelChange = (model: Model) => { 46 | setSelectedModel(model) 47 | saveSelectedModel(model) 48 | } 49 | 50 | // set up conversations on app load 51 | useEffect(() => { 52 | getConversations() 53 | deleteUnusedConversations() 54 | startNewConversation() 55 | }, [db]) 56 | 57 | // Initialize sidebar visibility based on screen size 58 | useEffect(() => { 59 | // const isMobile = window.matchMedia("(max-width: 768px)").matches; 60 | // setSidebarVisible(!isMobile); 61 | }, []) 62 | 63 | const getConversations = async () => { 64 | if (!db) return 65 | 66 | const conversations = (await db.getAll(storeName)) as Conversation[] 67 | const inverseConversations = conversations.reverse() 68 | setConversations(inverseConversations) 69 | } 70 | 71 | const deleteConversation = async (id: number, showPromptToUser = true) => { 72 | try { 73 | if (showPromptToUser && !window.confirm('Are you sure you want to delete this conversation?')) { 74 | return 75 | } 76 | 77 | await db?.delete(storeName, id) 78 | setConversations((prev) => prev.filter((conv) => conv.id !== id)) 79 | setConversationId(conversations[0]?.id) 80 | } catch (error) { 81 | console.error('Failed to delete conversation:', error) 82 | } 83 | } 84 | 85 | const editConversationTitle = async (id: number, newTitle: string) => { 86 | const conversation = (await db!.get(storeName, id)) as Conversation 87 | conversation.title = newTitle 88 | await db!.put(storeName, conversation) 89 | setConversations((prev) => prev.map((conv) => (conv.id === id ? { ...conv, title: newTitle } : conv))) 90 | } 91 | 92 | const startNewConversation = async () => { 93 | //create unique id for new conversation 94 | setConversationId(Date.now() + Math.floor(Math.random() * 1000)) 95 | // if (window.matchMedia("(max-width: 768px)").matches) { 96 | // setSidebarVisible(false); 97 | // } 98 | } 99 | 100 | // delete conversations with no messages 101 | const deleteUnusedConversations = async () => { 102 | if (!db) return 103 | const conversations = (await db.getAll(storeName)) as Conversation[] 104 | const unusedConversations = conversations.filter((conversation) => conversation.messages.length === 0) 105 | 106 | for (const conversation of unusedConversations) { 107 | deleteConversation(conversation.id as number, false) 108 | } 109 | } 110 | 111 | return ( 112 | <div 113 | className="flex min-h-screen w-screen animated-bg-container" 114 | style={{ '--random-delay': `${animationDelay}s` } as React.CSSProperties} 115 | > 116 | <div className="flex flex-row flex-grow flex-1 min-h-screen relative"> 117 | {/* Sidebar and Navbar components */} 118 | {false && ( 119 | <> 120 | <ChatSidebar 121 | sidebarVisible={sidebarVisible} 122 | setSidebarVisible={setSidebarVisible} 123 | conversations={conversations} 124 | conversationId={conversationId} 125 | setConversationId={setConversationId} 126 | deleteConversation={deleteConversation} 127 | editConversationTitle={editConversationTitle} 128 | startNewConversation={startNewConversation} 129 | selectedModel={selectedModel} 130 | onModelChange={handleModelChange} 131 | apiKeyUpdateTrigger={apiKeyUpdateTrigger} 132 | onMcpToolsUpdate={setMcpTools} 133 | mcpTools={mcpTools} 134 | /> 135 | <ChatNavbar sidebarVisible={sidebarVisible} setSidebarVisible={setSidebarVisible} /> 136 | </> 137 | )} 138 | <div className="flex flex-col flex-grow h-full w-full"> 139 | <ConversationThread 140 | conversations={conversations} 141 | conversationId={conversationId} 142 | setConversationId={setConversationId} 143 | setConversations={setConversations} 144 | db={db as IDBPDatabase} 145 | selectedModel={selectedModel} 146 | onApiKeyUpdate={handleApiKeyUpdate} 147 | onModelChange={handleModelChange} 148 | apiKeyUpdateTrigger={apiKeyUpdateTrigger} 149 | mcpTools={mcpTools} 150 | onMcpToolsUpdate={setMcpTools} 151 | addToFavorites={addToFavorites} 152 | models={models} 153 | toggleFavorite={toggleFavorite} 154 | isFavorite={isFavorite} 155 | getFavoriteModels={getFavoriteModels} 156 | /> 157 | </div> 158 | </div> 159 | </div> 160 | ) 161 | } 162 | 163 | export default ChatApp 164 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { FormEvent, KeyboardEvent, useRef, useEffect, Dispatch, SetStateAction } from 'react' 2 | import { Send, Pause } from 'lucide-react' 3 | 4 | interface ChatInputProps { 5 | input: string 6 | setInput: Dispatch<SetStateAction<string>> 7 | handleSubmit: (e: FormEvent) => void 8 | isLoading: boolean 9 | streamStarted: boolean 10 | controller: AbortController 11 | messagesCount: number 12 | } 13 | 14 | const ChatInput: React.FC<ChatInputProps> = ({ input, setInput, handleSubmit, isLoading, streamStarted, controller, messagesCount }) => { 15 | const textareaRef = useRef<HTMLTextAreaElement>(null) 16 | 17 | useEffect(() => { 18 | if (textareaRef.current) { 19 | textareaRef.current.style.height = 'auto' 20 | textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px` 21 | } 22 | }, [input]) 23 | 24 | const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { 25 | if (e.key === 'Enter' && !e.shiftKey) { 26 | e.preventDefault() 27 | handleSubmit(e as unknown as FormEvent) 28 | } 29 | if (e.key === 'Enter' && e.shiftKey) { 30 | e.preventDefault() 31 | setInput((prev: string) => prev + '\n') 32 | } 33 | } 34 | 35 | return ( 36 | <div className="relative rounded-lg border border-zinc-200 bg-white shadow-sm"> 37 | <form onSubmit={handleSubmit} className={`w-full items-center`}> 38 | <div className="flex justify-end items-center"> 39 | <textarea 40 | id="chat-input" 41 | ref={textareaRef} 42 | value={input} 43 | rows={1} 44 | onFocus={() => console.clear()} 45 | onChange={(e) => setInput(e.target.value)} 46 | onKeyDown={handleKeyDown} 47 | className="w-full flex-1 p-3 bg-transparent resize-none focus:outline-none" 48 | placeholder={messagesCount === 0 ? 'Ask anything...' : 'Type your message...'} 49 | style={{ maxHeight: '200px' }} 50 | /> 51 | <button 52 | type={isLoading && streamStarted ? 'button' : 'submit'} 53 | onClick={isLoading && streamStarted ? () => controller.abort() : undefined} 54 | className="m-2 hover:bg-zinc-100 rounded-lg p-2 text-zinc-600 hover:text-zinc-900" 55 | disabled={!isLoading && !input.trim()} 56 | > 57 | {isLoading && streamStarted ? <Pause size={20} /> : <Send size={20} />} 58 | </button> 59 | </div> 60 | </form> 61 | </div> 62 | ) 63 | } 64 | 65 | export default ChatInput 66 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/ChatNavbar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { PanelLeftOpen } from 'lucide-react' 3 | 4 | interface ChatNavbarProps { 5 | sidebarVisible: boolean 6 | setSidebarVisible: React.Dispatch<React.SetStateAction<boolean>> 7 | } 8 | 9 | const ChatNavbar: React.FC<ChatNavbarProps> = ({ sidebarVisible, setSidebarVisible }) => { 10 | return ( 11 | <div className="sticky top-0 bg-white border-b border-zinc-200 z-10"> 12 | <div className="my-3 mx-2 flex items-center justify-between"> 13 | <div className="flex items-center"> 14 | {!sidebarVisible && ( 15 | <div> 16 | <button 17 | onClick={() => setSidebarVisible(!sidebarVisible)} 18 | className=" 19 | rounded-lg p-[0.4em] 20 | hover:bg-zinc-100 hover:cursor-pointer 21 | mr-2 transition-colors text-zinc-600 hover:text-zinc-800" 22 | > 23 | <PanelLeftOpen size={20} /> 24 | </button> 25 | </div> 26 | )} 27 | <h1 className="text-base font-semibold text-zinc-600 ml-2">AI Chat Template</h1> 28 | </div> 29 | </div> 30 | </div> 31 | ) 32 | } 33 | 34 | export default ChatNavbar 35 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/ChatSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Edit, Trash2, PanelLeftClose, PanelLeftOpen, SquarePen } from 'lucide-react' 3 | import ModelSelector from './ModelSelector' 4 | import { McpServers } from './McpServers' 5 | import { type Model } from '../types/models' 6 | import { type Message } from '../types' 7 | import { type Tool } from 'use-mcp/react' 8 | 9 | interface Conversation { 10 | id?: number 11 | title: string 12 | messages: Message[] 13 | } 14 | 15 | interface ChatSidebarProps { 16 | sidebarVisible: boolean 17 | setSidebarVisible: React.Dispatch<React.SetStateAction<boolean>> 18 | conversations: Conversation[] 19 | conversationId: number | undefined 20 | setConversationId: React.Dispatch<React.SetStateAction<number | undefined>> 21 | deleteConversation: (id: number) => void 22 | editConversationTitle: (id: number, newTitle: string) => void 23 | startNewConversation: () => void 24 | selectedModel: Model 25 | onModelChange: (model: Model) => void 26 | apiKeyUpdateTrigger: number 27 | onMcpToolsUpdate: (tools: Tool[]) => void 28 | mcpTools: Tool[] 29 | } 30 | 31 | const ChatSidebar: React.FC<ChatSidebarProps> = ({ 32 | sidebarVisible, 33 | setSidebarVisible, 34 | conversations, 35 | conversationId, 36 | setConversationId, 37 | deleteConversation, 38 | editConversationTitle, 39 | startNewConversation, 40 | selectedModel, 41 | onModelChange, 42 | apiKeyUpdateTrigger, 43 | onMcpToolsUpdate, 44 | mcpTools, 45 | }) => { 46 | const handleConversationClick = (id: number | undefined) => { 47 | setConversationId(id) 48 | if (window.matchMedia('(max-width: 768px)').matches) { 49 | setSidebarVisible(false) 50 | } 51 | } 52 | 53 | return ( 54 | <> 55 | {/* Mobile overlay */} 56 | {sidebarVisible && <div className="md:hidden fixed inset-0 bg-black/30 z-20" onClick={() => setSidebarVisible(false)} />} 57 | <div 58 | className={` 59 | fixed md:relative 60 | z-30 md:z-auto 61 | h-full 62 | bg-zinc-50 63 | transition-all duration-300 64 | 65 | ${sidebarVisible ? 'w-64 lg:w-[32rem] translate-x-0' : 'w-0 -translate-x-full md:translate-x-0'} 66 | `} 67 | > 68 | <div 69 | className={`m-2 flex items-center justify-between 70 | ${sidebarVisible ? 'sticky' : 'hidden'} 71 | `} 72 | > 73 | <button 74 | onClick={() => setSidebarVisible(!sidebarVisible)} 75 | className=" 76 | rounded-lg p-[0.4em] 77 | hover:bg-zinc-100 hover:cursor-pointer 78 | transition-colors text-zinc-600 hover:text-zinc-800" 79 | > 80 | {sidebarVisible ? <PanelLeftClose size={20} /> : <PanelLeftOpen size={20} />} 81 | </button> 82 | <button 83 | className="rounded-lg p-[0.4em] 84 | hover:bg-zinc-100 hover:cursor-pointer 85 | transition-colors text-zinc-600 hover:text-zinc-800" 86 | onClick={startNewConversation} 87 | > 88 | <SquarePen size={20} /> 89 | </button> 90 | </div> 91 | <div 92 | className=" h-[calc(100%-3rem)] 93 | overflow-y-scroll scrollbar-thin 94 | flex flex-col justify-between border-r border-zinc-200 transition-all duration-300" 95 | > 96 | <div className="flex flex-col"> 97 | <ul className="p-2 space-y-1"> 98 | {conversations.map((conversation) => ( 99 | <li 100 | key={conversation.id} 101 | className={`cursor-pointer p-2 transition-colors rounded-lg ${ 102 | conversation.id === conversationId || (!conversationId && !conversation.id) 103 | ? 'bg-zinc-200 text-zinc-900' 104 | : 'hover:bg-zinc-200 text-zinc-600' 105 | }`} 106 | onClick={() => handleConversationClick(conversation.id)} 107 | > 108 | <div className="flex items-center justify-between"> 109 | <span className="truncate flex-grow text-sm">{conversation.title}</span> 110 | <div className="flex space-x-2 ml-2"> 111 | <button 112 | className="opacity-60 hover:opacity-100 transition-opacity" 113 | onClick={(e) => { 114 | e.stopPropagation() 115 | const newTitle = prompt('Enter new title:', conversation.title) 116 | if (newTitle) editConversationTitle(conversation.id!, newTitle) 117 | }} 118 | > 119 | <Edit size={14} /> 120 | </button> 121 | <button 122 | className="opacity-60 hover:opacity-100 transition-opacity" 123 | onClick={(e) => { 124 | e.stopPropagation() 125 | deleteConversation(conversation.id!) 126 | setConversationId(undefined) 127 | }} 128 | > 129 | <Trash2 size={14} /> 130 | </button> 131 | </div> 132 | </div> 133 | </li> 134 | ))} 135 | </ul> 136 | </div> 137 | 138 | {/* Model Selector and MCP Servers at bottom */} 139 | <div className="p-2 border-t border-zinc-200 space-y-3"> 140 | <ModelSelector 141 | selectedModel={selectedModel} 142 | onModelChange={onModelChange} 143 | apiKeyUpdateTrigger={apiKeyUpdateTrigger} 144 | toolsAvailable={mcpTools.length > 0} 145 | /> 146 | <McpServers onToolsUpdate={onMcpToolsUpdate} /> 147 | </div> 148 | </div> 149 | </div> 150 | </> 151 | ) 152 | } 153 | 154 | export default ChatSidebar 155 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/OAuthCallback.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react' 2 | import { useSearchParams } from 'react-router-dom' 3 | import { completeOAuthFlow } from '../utils/auth' 4 | import { SupportedProvider } from '../types/models' 5 | 6 | interface OAuthCallbackProps { 7 | provider: SupportedProvider 8 | } 9 | 10 | const OAuthCallback: React.FC<OAuthCallbackProps> = ({ provider }) => { 11 | const [searchParams] = useSearchParams() 12 | const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading') 13 | const [error, setError] = useState<string | null>(null) 14 | const executedRef = useRef(false) 15 | 16 | useEffect(() => { 17 | const handleCallback = async () => { 18 | if (executedRef.current) { 19 | console.log('DEBUG: Skipping duplicate OAuth callback execution') 20 | return 21 | } 22 | executedRef.current = true 23 | 24 | try { 25 | const code = searchParams.get('code') 26 | const state = searchParams.get('state') 27 | const error = searchParams.get('error') 28 | 29 | if (error) { 30 | throw new Error(`OAuth error: ${error}`) 31 | } 32 | 33 | if (!code) { 34 | throw new Error('Missing authorization code') 35 | } 36 | 37 | // OpenRouter doesn't use state parameter, but other providers might 38 | const stateToUse = state || 'no-state' 39 | await completeOAuthFlow(provider, code, stateToUse) 40 | setStatus('success') 41 | 42 | // Close popup after successful authentication 43 | // Give extra time for debugging in development 44 | setTimeout(() => { 45 | if (window.opener) { 46 | window.opener.postMessage({ type: 'oauth_success', provider }, '*') 47 | window.close() 48 | } else { 49 | // Redirect to main page if not in popup 50 | window.location.href = '/' 51 | } 52 | }, 3000) 53 | } catch (err) { 54 | console.error('OAuth callback error:', err) 55 | setError(err instanceof Error ? err.message : 'Unknown error') 56 | setStatus('error') 57 | } 58 | } 59 | 60 | handleCallback() 61 | }, [searchParams, provider]) 62 | 63 | return ( 64 | <div className="min-h-screen flex items-center justify-center bg-gray-50"> 65 | <div className="max-w-md w-full bg-white shadow-lg rounded-lg p-8"> 66 | <div className="text-center"> 67 | {status === 'loading' && ( 68 | <> 69 | <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div> 70 | <h2 className="text-xl font-semibold text-gray-900 mb-2">Completing Authentication</h2> 71 | <p className="text-gray-600">Connecting to {provider}...</p> 72 | </> 73 | )} 74 | 75 | {status === 'success' && ( 76 | <> 77 | <div className="text-green-500 mb-4"> 78 | <svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 20 20"> 79 | <path 80 | fillRule="evenodd" 81 | d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" 82 | clipRule="evenodd" 83 | /> 84 | </svg> 85 | </div> 86 | <h2 className="text-xl font-semibold text-gray-900 mb-2">Authentication Successful!</h2> 87 | <p className="text-gray-600 mb-4">Successfully connected to {provider}. You can now close this window.</p> 88 | <button 89 | onClick={() => window.close()} 90 | className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors" 91 | > 92 | Close Window 93 | </button> 94 | </> 95 | )} 96 | 97 | {status === 'error' && ( 98 | <> 99 | <div className="text-red-500 mb-4"> 100 | <svg className="w-12 h-12 mx-auto" fill="currentColor" viewBox="0 0 20 20"> 101 | <path 102 | fillRule="evenodd" 103 | d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" 104 | clipRule="evenodd" 105 | /> 106 | </svg> 107 | </div> 108 | <h2 className="text-xl font-semibold text-gray-900 mb-2">Authentication Failed</h2> 109 | <p className="text-gray-600 mb-4">{error || 'An error occurred during authentication'}</p> 110 | <button 111 | onClick={() => window.close()} 112 | className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors" 113 | > 114 | Close Window 115 | </button> 116 | </> 117 | )} 118 | </div> 119 | </div> 120 | </div> 121 | ) 122 | } 123 | 124 | export default OAuthCallback 125 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/ProviderModelsModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { createPortal } from 'react-dom' 3 | import { X, Star, Search } from 'lucide-react' 4 | import { type Model, type Provider } from '../types/models' 5 | 6 | interface ProviderModelsModalProps { 7 | isOpen: boolean 8 | onClose: () => void 9 | provider: Provider | null 10 | models: Model[] 11 | favorites: string[] 12 | onToggleFavorite: (modelId: string) => void 13 | isFavorite: (modelId: string) => boolean 14 | onModelSelect: (model: Model) => void 15 | toolsAvailable?: boolean 16 | } 17 | 18 | const ProviderModelsModal: React.FC<ProviderModelsModalProps> = ({ 19 | isOpen, 20 | onClose, 21 | provider, 22 | models, 23 | onToggleFavorite, 24 | isFavorite, 25 | onModelSelect, 26 | toolsAvailable = false, 27 | }) => { 28 | const [searchTerm, setSearchTerm] = useState('') 29 | const [showToolsOnly, setShowToolsOnly] = useState(false) 30 | 31 | if (!isOpen || !provider) return null 32 | 33 | const providerModels = models.filter((model) => model.provider.id === provider.id) 34 | 35 | const filteredModels = providerModels.filter((model) => { 36 | const matchesSearch = model.name.toLowerCase().includes(searchTerm.toLowerCase()) 37 | const matchesTools = !showToolsOnly || model.supportsTools 38 | return matchesSearch && matchesTools 39 | }) 40 | 41 | const handleStarClick = (modelId: string, e: React.MouseEvent) => { 42 | e.stopPropagation() 43 | onToggleFavorite(modelId) 44 | } 45 | 46 | const modalContent = ( 47 | <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 48 | <div className="bg-white rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] overflow-hidden"> 49 | {/* Header */} 50 | <div className="flex items-center justify-between p-4 border-b border-zinc-200"> 51 | <div className="flex items-center gap-3"> 52 | <span className="text-2xl">{provider.logo}</span> 53 | <div> 54 | <h2 className="text-lg font-semibold text-zinc-900">{provider.name} Models</h2> 55 | <p className="text-sm text-zinc-500">Select models to add to your favorites</p> 56 | </div> 57 | </div> 58 | <button onClick={onClose} className="p-2 text-zinc-400 hover:text-zinc-600 rounded-md hover:bg-zinc-100"> 59 | <X size={20} /> 60 | </button> 61 | </div> 62 | 63 | {/* Search and Filters */} 64 | <div className="p-4 border-b border-zinc-200"> 65 | <div className="relative mb-3"> 66 | <Search size={16} className="absolute left-3 top-2.5 text-zinc-400" /> 67 | <input 68 | type="text" 69 | value={searchTerm} 70 | onChange={(e) => setSearchTerm(e.target.value)} 71 | placeholder="Search models..." 72 | className="w-full pl-10 pr-3 py-2 text-sm border border-zinc-200 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" 73 | /> 74 | </div> 75 | 76 | {toolsAvailable && ( 77 | <button 78 | onClick={() => setShowToolsOnly(!showToolsOnly)} 79 | className={`px-3 py-1 text-xs rounded-full border transition-colors ${ 80 | showToolsOnly ? 'bg-green-100 text-green-700 border-green-300' : 'bg-white text-zinc-600 border-zinc-200 hover:bg-zinc-50' 81 | }`} 82 | > 83 | 🔧 Tools Only 84 | </button> 85 | )} 86 | </div> 87 | 88 | {/* Models List */} 89 | <div className="max-h-96 overflow-y-auto"> 90 | {filteredModels.length === 0 ? ( 91 | <div className="p-8 text-center text-zinc-500">No models found matching your criteria</div> 92 | ) : ( 93 | filteredModels.map((model) => ( 94 | <div 95 | key={model.id} 96 | className="flex items-center justify-between p-4 border-b border-zinc-100 last:border-b-0 hover:bg-zinc-50" 97 | > 98 | <div className="flex items-center gap-3 flex-1"> 99 | <button 100 | onClick={(e) => handleStarClick(model.id, e)} 101 | className={`transition-colors ${isFavorite(model.id) ? 'text-yellow-500' : 'text-zinc-300 hover:text-yellow-400'}`} 102 | > 103 | <Star size={18} fill={isFavorite(model.id) ? 'currentColor' : 'none'} /> 104 | </button> 105 | 106 | <div className="flex-1"> 107 | <div className="font-medium text-zinc-900">{model.name}</div> 108 | <div className="text-sm text-zinc-500 flex items-center gap-2"> 109 | {model.supportsTools && <span className="text-green-600">🔧 Tools</span>} 110 | {model.reasoning && <span className="text-purple-600">🧠 Reasoning</span>} 111 | {model.attachment && <span className="text-blue-600">📎 Attachments</span>} 112 | <span className="text-zinc-400">•</span> 113 | <span>{(model.contextLimit / 1000).toFixed(0)}K context</span> 114 | </div> 115 | </div> 116 | </div> 117 | 118 | <button 119 | onClick={() => onModelSelect(model)} 120 | className="px-3 py-1 text-sm bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors" 121 | > 122 | Select 123 | </button> 124 | </div> 125 | )) 126 | )} 127 | </div> 128 | 129 | {/* Footer */} 130 | <div className="p-4 border-t border-zinc-200 bg-zinc-50"> 131 | <div className="text-sm text-zinc-600"> 132 | {filteredModels.length} model{filteredModels.length !== 1 ? 's' : ''} available 133 | {toolsAvailable && showToolsOnly && ' with tool support'} 134 | </div> 135 | </div> 136 | </div> 137 | </div> 138 | ) 139 | 140 | return createPortal(modalContent, document.body) 141 | } 142 | 143 | export default ProviderModelsModal 144 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/messages/AssistantMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { type AssistantMessage } from '../../types' 3 | import ReactMarkdown from 'react-markdown' 4 | import remarkGfm from 'remark-gfm' 5 | import rehypeHighlight from 'rehype-highlight' 6 | 7 | interface AssistantMessageProps { 8 | message: AssistantMessage 9 | } 10 | 11 | const AssistantMessage: React.FC<AssistantMessageProps> = ({ message }) => { 12 | return ( 13 | <div className={`flex justify-start`}> 14 | <div className={`text-zinc-900 w-full`}> 15 | <div className="prose prose-zinc prose-tight"> 16 | <ReactMarkdown 17 | remarkPlugins={[remarkGfm]} 18 | rehypePlugins={[rehypeHighlight]} 19 | components={{ 20 | table: ({ children }) => <div className="overflow-x-scroll text-sm">{children}</div>, 21 | }} 22 | > 23 | {message.content} 24 | </ReactMarkdown> 25 | </div> 26 | </div> 27 | </div> 28 | ) 29 | } 30 | 31 | export default AssistantMessage 32 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/messages/ChatMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactMarkdown from 'react-markdown' 3 | import remarkGfm from 'remark-gfm' 4 | import rehypeHighlight from 'rehype-highlight' 5 | import { type Message } from '../../types' 6 | import ToolCallMessage from './ToolCallMessage.tsx' 7 | import ToolResultMessage from './ToolResultMessage.tsx' 8 | import AssistantMessage from './AssistantMessage.tsx' 9 | import ReasoningMessage from './ReasoningMessage.tsx' 10 | import ErrorMessage from './ErrorMessage.tsx' 11 | 12 | interface ChatMessageProps { 13 | message: Message 14 | } 15 | 16 | const ChatMessage: React.FC<ChatMessageProps> = ({ message }) => { 17 | // Handle tool calls and results differently 18 | if (message.role === 'tool-call') { 19 | return <ToolCallMessage message={message} /> 20 | } 21 | 22 | if (message.role === 'tool-result') { 23 | return <ToolResultMessage message={message} /> 24 | } 25 | 26 | if (message.role === 'error') { 27 | return <ErrorMessage error={message.content} /> 28 | } 29 | 30 | if (message.role === 'assistant') { 31 | if (message.type === 'reasoning') { 32 | return <ReasoningMessage message={message} /> 33 | } else { 34 | return <AssistantMessage message={message} /> 35 | } 36 | } 37 | 38 | // Handle regular messages (user, assistant, system) 39 | return ( 40 | <div className={`flex justify-end`}> 41 | <div className={`max-w-[80%] rounded-2xl px-5 py-3 shadow bg-white text-black mb-3`}> 42 | <div className="prose prose-zinc prose-tight"> 43 | <ReactMarkdown 44 | remarkPlugins={[remarkGfm]} 45 | rehypePlugins={[rehypeHighlight]} 46 | components={{ 47 | table: ({ children }) => <div className="overflow-x-scroll text-sm">{children}</div>, 48 | }} 49 | > 50 | {message.content} 51 | </ReactMarkdown> 52 | </div> 53 | </div> 54 | </div> 55 | ) 56 | } 57 | 58 | export default ChatMessage 59 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/messages/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | interface ErrorMessageProps { 4 | error: string 5 | } 6 | 7 | const ErrorMessage: React.FC<ErrorMessageProps> = ({ error }) => { 8 | const [isExpanded, setIsExpanded] = useState(false) 9 | 10 | return ( 11 | <div className="bg-red-50 border border-red-200 rounded-lg p-3 my-2"> 12 | <div className="flex items-start gap-2"> 13 | <span className="text-red-500 text-sm font-medium">⚠️ Error:</span> 14 | <div className="flex-1 min-w-0"> 15 | <div 16 | className={`text-red-700 text-xs leading-relaxed ${!isExpanded ? 'max-h-[120px] overflow-hidden' : ''}`} 17 | style={{ 18 | wordBreak: 'break-word', 19 | whiteSpace: 'pre-wrap', 20 | }} 21 | > 22 | {error} 23 | </div> 24 | {error.length > 200 && ( 25 | <button 26 | onClick={() => setIsExpanded(!isExpanded)} 27 | className="text-red-600 hover:text-red-800 text-xs mt-1 underline focus:outline-none" 28 | > 29 | {isExpanded ? 'Show less' : 'Show more'} 30 | </button> 31 | )} 32 | </div> 33 | </div> 34 | </div> 35 | ) 36 | } 37 | 38 | export default ErrorMessage 39 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/messages/ReasoningMessage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { type ReasoningMessage } from '../../types' 3 | 4 | interface ReasoningMessageProps { 5 | message: ReasoningMessage 6 | } 7 | 8 | const ReasoningMessage: React.FC<ReasoningMessageProps> = ({ message }) => { 9 | const [isExpanded, setIsExpanded] = useState(message.isReasoningStreaming) // Start expanded when streaming 10 | 11 | // Auto-collapse instantly when streaming finishes 12 | useEffect(() => { 13 | if (!message.isReasoningStreaming && message.reasoningStartTime && message.reasoningEndTime) { 14 | setIsExpanded(false) 15 | } 16 | }, [message.isReasoningStreaming, message.reasoningStartTime, message.reasoningEndTime]) 17 | 18 | if (!message.content || message.content.trim().length === 0) { 19 | return null 20 | } 21 | 22 | const toggleExpanded = () => { 23 | setIsExpanded(!isExpanded) 24 | } 25 | 26 | // Calculate thinking duration 27 | const duration = 28 | message.reasoningStartTime && message.reasoningEndTime ? (message.reasoningEndTime - message.reasoningStartTime) / 1000 : null 29 | 30 | // Format duration nicely 31 | const formatDuration = (seconds: number) => { 32 | if (seconds < 0.001) return `< 1ms` 33 | if (seconds < 1) return `${Math.round(seconds * 1000)}ms` 34 | return `${seconds.toFixed(1)}s` 35 | } 36 | 37 | return ( 38 | <div className={`flex justify-start`}> 39 | <div className={`text-zinc-900 w-full`}> 40 | <div 41 | className={`text-xs/5 text-zinc-600 border border-zinc-200 rounded-lg p-3 bg-zinc-50 cursor-pointer hover:bg-zinc-100 ${ 42 | isExpanded ? 'border-zinc-300' : '' 43 | }`} 44 | onClick={toggleExpanded} 45 | > 46 | <div className="flex items-start gap-2"> 47 | <span className="text-zinc-400 text-xs mt-0.5 flex-shrink-0">💭</span> 48 | 49 | {isExpanded ? ( 50 | // Expanded view: show reasoning content 51 | <div 52 | className={`flex-1 ${message.isReasoningStreaming ? 'overflow-hidden whitespace-nowrap flex justify-end' : 'whitespace-pre-wrap'}`} 53 | > 54 | <span className={message.isReasoningStreaming ? 'inline-block' : ''}> 55 | {message.content} 56 | {message.isReasoningStreaming && <span className="inline-block w-2 h-4 bg-zinc-400 ml-1 animate-pulse"></span>} 57 | </span> 58 | </div> 59 | ) : ( 60 | // Collapsed view: show timing summary 61 | <div className="flex-1"> 62 | <span className="text-zinc-500"> 63 | {message.isReasoningStreaming 64 | ? 'Thinking...' 65 | : typeof duration === 'number' 66 | ? `Thought for ${formatDuration(duration)}` 67 | : 'Thought process'} 68 | </span> 69 | </div> 70 | )} 71 | 72 | <button 73 | className="text-zinc-400 hover:text-zinc-600 text-xs ml-auto flex-shrink-0" 74 | onClick={(e) => { 75 | e.stopPropagation() 76 | toggleExpanded() 77 | }} 78 | > 79 | {/*{isExpanded ? '↑' : '↓'}*/} 80 | </button> 81 | </div> 82 | </div> 83 | </div> 84 | </div> 85 | ) 86 | } 87 | 88 | export default ReasoningMessage 89 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/messages/ToolCallMessage.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { ChevronDown, Wrench } from 'lucide-react' 3 | import { type ToolCallMessage } from '../../types' 4 | 5 | interface ToolCallMessageProps { 6 | message: ToolCallMessage 7 | } 8 | 9 | const ToolCallMessage: React.FC<ToolCallMessageProps> = ({ message }) => { 10 | const [expanded, setExpanded] = useState(false) 11 | 12 | const json = JSON.stringify(message.toolArgs) 13 | const argsPreview = json.substring(0, 100) 14 | const shouldTruncate = json.length > 100 15 | 16 | return ( 17 | <div className="flex gap-3 py-2 px-4 bg-blue-50 border border-blue-200 rounded-lg"> 18 | <div className={`flex-shrink-0 h-full ${shouldTruncate ? 'mt-1' : 'mt-2.5'}`}> 19 | <Wrench size={16} className="text-blue-600" /> 20 | </div> 21 | <div className="flex-grow min-w-0"> 22 | <div className="flex items-center gap-2 mb-1 overflow-hidden"> 23 | <span className="font-medium text-blue-800 text-sm flex-shrink-0">Tool Call</span> 24 | <span className="text-blue-600 text-sm font-mono bg-blue-100 px-2 py-0.5 rounded">{message.toolName}</span> 25 | {!shouldTruncate && ( 26 | <div className={`text-sm text-blue-700 font-mono bg-blue-100 p-2 rounded ml-auto`}> 27 | <span className={'truncate'}>{argsPreview}</span> 28 | </div> 29 | )} 30 | </div> 31 | 32 | {shouldTruncate && ( 33 | <div 34 | className={`text-sm text-blue-700 font-mono bg-blue-100 p-2 rounded cursor-pointer hover:bg-blue-200`} 35 | onClick={() => setExpanded(!expanded)} 36 | > 37 | {expanded ? ( 38 | <pre className="mt-2 whitespace-pre-wrap break-words max-h-64 overflow-y-auto"> 39 | {JSON.stringify(message.toolArgs, null, 2)} 40 | </pre> 41 | ) : ( 42 | <div className="flex items-start justify-between"> 43 | <span className={'truncate'}>{argsPreview}</span> 44 | <button className="ml-2 flex-shrink-0">{<ChevronDown size={14} />}</button> 45 | </div> 46 | )} 47 | </div> 48 | )} 49 | </div> 50 | </div> 51 | ) 52 | } 53 | 54 | export default ToolCallMessage 55 | -------------------------------------------------------------------------------- /examples/chat-ui/src/components/messages/ToolResultMessage.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { CheckCircle, ChevronDown } from 'lucide-react' 3 | import { type ToolResultMessage } from '../../types' 4 | 5 | interface ToolResultMessageProps { 6 | message: ToolResultMessage 7 | } 8 | 9 | const ToolResultMessage: React.FC<ToolResultMessageProps> = ({ message }) => { 10 | const [expanded, setExpanded] = useState(false) 11 | 12 | const json = JSON.stringify(message.toolResult) 13 | const resultPreview = json.substring(0, 48) 14 | const shouldTruncate = json.length > 48 15 | 16 | return ( 17 | <div className="flex gap-3 py-3 px-4 bg-green-50 border border-green-300 rounded-lg"> 18 | <div className={`flex-shrink-0 h-full ${shouldTruncate ? 'mt-1' : 'mt-2.5'}`}> 19 | <CheckCircle size={16} className="text-green-600" /> 20 | </div> 21 | <div className="flex-grow min-w-0"> 22 | <div className="flex items-center gap-2 mb-1 overflow-hidden"> 23 | <span className="font-medium text-green-800 text-sm flex-shrink-0">Tool Result</span> 24 | <span className="text-green-600 text-sm font-mono bg-green-100 px-2 py-0.5 rounded">{message.toolName}</span> 25 | {!shouldTruncate && ( 26 | <div className={`text-sm text-green-700 font-mono bg-green-100 p-2 rounded ml-auto`}> 27 | <span className={'truncate'}>{resultPreview}</span> 28 | </div> 29 | )} 30 | </div> 31 | 32 | {shouldTruncate && ( 33 | <div 34 | className={`text-sm text-green-700 font-mono bg-green-100 p-2 rounded cursor-pointer hover:bg-green-200`} 35 | onClick={() => setExpanded(!expanded)} 36 | > 37 | {expanded ? ( 38 | <pre className="mt-2 whitespace-pre-wrap break-words max-h-64 overflow-y-auto"> 39 | {JSON.stringify(message.toolResult, null, 2)} 40 | </pre> 41 | ) : ( 42 | <div className="flex items-start justify-between"> 43 | <span className={'truncate'}>{resultPreview}</span> 44 | <button className="ml-2 flex-shrink-0">{<ChevronDown size={14} />}</button> 45 | </div> 46 | )} 47 | </div> 48 | )} 49 | </div> 50 | </div> 51 | ) 52 | } 53 | 54 | export default ToolResultMessage 55 | -------------------------------------------------------------------------------- /examples/chat-ui/src/consts.ts: -------------------------------------------------------------------------------- 1 | export const storeName = 'conversations' 2 | export const dbName = 'localgpt' 3 | -------------------------------------------------------------------------------- /examples/chat-ui/src/hooks/useAutoscroll.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react' 2 | 3 | export const useAutoscroll = () => { 4 | const messagesEndRef = useRef<HTMLDivElement>(null) 5 | const messagesContainerRef = useRef<HTMLDivElement>(null) 6 | const shouldAutoScrollRef = useRef(true) 7 | 8 | useEffect(() => { 9 | const container = messagesContainerRef.current 10 | if (!container) return 11 | 12 | const handleScroll = () => { 13 | const isAtBottom = container.scrollHeight - container.clientHeight - container.scrollTop <= 200 14 | shouldAutoScrollRef.current = isAtBottom 15 | } 16 | 17 | container.addEventListener('scroll', handleScroll) 18 | return () => { 19 | container.removeEventListener('scroll', handleScroll) 20 | } 21 | }, []) 22 | 23 | const scrollToBottom = (force?: boolean) => { 24 | if (shouldAutoScrollRef.current || force) { 25 | messagesEndRef.current?.scrollIntoView({ behavior: force ? 'instant' : 'smooth' }) 26 | } 27 | } 28 | 29 | return { 30 | messagesEndRef, 31 | messagesContainerRef, 32 | scrollToBottom, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/chat-ui/src/hooks/useConversationUpdater.ts: -------------------------------------------------------------------------------- 1 | import { type Conversation } from '../types' 2 | 3 | interface UseConversationUpdaterProps { 4 | conversationId?: number 5 | setConversations: React.Dispatch<React.SetStateAction<Conversation[]>> 6 | } 7 | 8 | export const useConversationUpdater = ({ conversationId, setConversations }: UseConversationUpdaterProps) => { 9 | const updateConversation = (updater: (conversation: Conversation) => Conversation) => { 10 | setConversations((prev) => { 11 | const conversation = prev.find((c) => c.id === conversationId) 12 | if (!conversation) { 13 | console.error(`Missing conversation for ID ${conversationId}`) 14 | return prev 15 | } 16 | 17 | const updatedConversation = updater(conversation) 18 | 19 | return prev.map((c) => (c.id === conversationId ? updatedConversation : c)) 20 | }) 21 | } 22 | 23 | return { updateConversation } 24 | } 25 | -------------------------------------------------------------------------------- /examples/chat-ui/src/hooks/useIndexedDB.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import { openDB, IDBPDatabase } from 'idb' 3 | import { dbName, storeName } from '../consts' 4 | 5 | export function useIndexedDB() { 6 | const [db, setDb] = useState<IDBPDatabase | null>(null) 7 | 8 | useEffect(() => { 9 | const initDb = async () => { 10 | const db = await openDB(dbName, 1, { 11 | upgrade(db) { 12 | db.createObjectStore(storeName, { 13 | keyPath: 'id', 14 | autoIncrement: true, 15 | }) 16 | }, 17 | }) 18 | setDb(db) 19 | } 20 | initDb() 21 | }, []) 22 | 23 | return db 24 | } 25 | -------------------------------------------------------------------------------- /examples/chat-ui/src/hooks/useModels.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react' 2 | import { Model, providers, SUPPORTED_PROVIDERS, FAVORITES_KEY } from '../types/models' 3 | 4 | // Load models data from generated JSON 5 | import modelsData from '../data/models.json' 6 | 7 | export function useModels() { 8 | const [models, setModels] = useState<Model[]>([]) 9 | const [favorites, setFavorites] = useState<string[]>([]) 10 | const [loading, setLoading] = useState(true) 11 | 12 | // Load models from data and favorites from localStorage 13 | useEffect(() => { 14 | const loadModels = () => { 15 | const allModels: Model[] = [] 16 | 17 | // Process each provider's models 18 | for (const providerId of SUPPORTED_PROVIDERS) { 19 | const provider = providers[providerId] 20 | const providerModels = modelsData[providerId] || {} 21 | 22 | for (const [modelId, modelData] of Object.entries(providerModels)) { 23 | const model: Model = { 24 | id: `${providerId}:${modelId}`, 25 | name: modelData.name, 26 | provider, 27 | modelId, 28 | supportsTools: modelData.tool_call, 29 | reasoning: modelData.reasoning, 30 | attachment: modelData.attachment, 31 | contextLimit: modelData.limit.context, 32 | outputLimit: modelData.limit.output, 33 | cost: modelData.cost, 34 | } 35 | allModels.push(model) 36 | } 37 | } 38 | 39 | setModels(allModels) 40 | setLoading(false) 41 | } 42 | 43 | const loadFavorites = () => { 44 | try { 45 | const saved = localStorage.getItem(FAVORITES_KEY) 46 | if (saved) { 47 | const parsed = JSON.parse(saved) 48 | setFavorites(parsed) 49 | } 50 | } catch (error) { 51 | console.error('Failed to load favorites from localStorage:', error) 52 | } 53 | } 54 | 55 | loadModels() 56 | loadFavorites() 57 | }, []) 58 | 59 | // Save favorites to localStorage when they change 60 | useEffect(() => { 61 | // Only save if favorites array has been loaded (not during initial state) 62 | if (loading) return 63 | 64 | try { 65 | localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites)) 66 | } catch (error) { 67 | console.error('Failed to save favorites to localStorage:', error) 68 | } 69 | }, [favorites, loading]) 70 | 71 | const toggleFavorite = useCallback((modelId: string) => { 72 | setFavorites((prev) => { 73 | if (prev.includes(modelId)) { 74 | return prev.filter((id) => id !== modelId) 75 | } else { 76 | return [...prev, modelId] 77 | } 78 | }) 79 | }, []) 80 | 81 | const addToFavorites = useCallback((modelId: string) => { 82 | setFavorites((prev) => { 83 | if (!prev.includes(modelId)) { 84 | return [...prev, modelId] 85 | } 86 | return prev 87 | }) 88 | }, []) 89 | 90 | const isFavorite = useCallback( 91 | (modelId: string) => { 92 | return favorites.includes(modelId) 93 | }, 94 | [favorites] 95 | ) 96 | 97 | const getFavoriteModels = useCallback(() => { 98 | return models.filter((model) => favorites.includes(model.id)) 99 | }, [models, favorites]) 100 | 101 | const getToolSupportingModels = useCallback(() => { 102 | return models.filter((model) => model.supportsTools) 103 | }, [models]) 104 | 105 | return { 106 | models, 107 | favorites, 108 | loading, 109 | toggleFavorite, 110 | addToFavorites, 111 | isFavorite, 112 | getFavoriteModels, 113 | getToolSupportingModels, 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /examples/chat-ui/src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react' 2 | import type { Theme } from '../types' 3 | 4 | export function useTheme() { 5 | const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem('theme') as Theme) || 'system') 6 | 7 | useEffect(() => { 8 | const root = window.document.documentElement 9 | root.classList.remove('light', 'dark') 10 | 11 | const effectiveTheme = theme === 'system' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : theme 12 | 13 | root.classList.add(effectiveTheme) 14 | if (theme === 'system') { 15 | localStorage.removeItem('theme') 16 | } else { 17 | localStorage.setItem('theme', theme) 18 | } 19 | }, [theme]) 20 | 21 | return [theme, setTheme] as const 22 | } 23 | -------------------------------------------------------------------------------- /examples/chat-ui/src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @plugin "@tailwindcss/typography"; 3 | @config "../tailwind.config.js"; 4 | 5 | @keyframes hue-rotate { 6 | 0% { 7 | filter: hue-rotate(0deg); 8 | } 9 | 100% { 10 | filter: hue-rotate(360deg); 11 | } 12 | } 13 | 14 | .animated-bg-container { 15 | position: relative; 16 | /*overflow: hidden;*/ 17 | /*height: 500vh;*/ 18 | } 19 | 20 | .animated-bg-container::before { 21 | content: ''; 22 | position: absolute; 23 | top: 0; 24 | left: 0; 25 | right: 0; 26 | bottom: 0; 27 | background: repeating-linear-gradient(150deg, #fdf2f8, #faf5ff, #eef2ff, #faf5ff, #fdf2f8 200vmax); 28 | animation: hue-rotate 60s linear infinite; 29 | animation-delay: var(--random-delay, 0s); 30 | z-index: -1; 31 | } 32 | -------------------------------------------------------------------------------- /examples/chat-ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | const ENABLE_STRICT_MODE = true 7 | 8 | createRoot(document.getElementById('root')!).render( 9 | ENABLE_STRICT_MODE ? ( 10 | <StrictMode> 11 | <App /> 12 | </StrictMode> 13 | ) : ( 14 | <App /> 15 | ) 16 | ) 17 | -------------------------------------------------------------------------------- /examples/chat-ui/src/styles/github.css: -------------------------------------------------------------------------------- 1 | .light { 2 | pre code.hljs { 3 | display: block; 4 | overflow-x: auto; 5 | padding: 1em; 6 | } 7 | code.hljs { 8 | padding: 3px 5px; 9 | } 10 | /*! 11 | Theme: GitHub 12 | Description: Light theme as seen on github.com 13 | Author: github.com 14 | Maintainer: @Hirse 15 | Updated: 2021-05-15 16 | 17 | Outdated base version: https://github.com/primer/github-syntax-light 18 | Current colors taken from GitHub's CSS 19 | */ 20 | .hljs { 21 | color: #24292e; 22 | background: #ffffff; 23 | } 24 | .hljs-doctag, 25 | .hljs-keyword, 26 | .hljs-meta .hljs-keyword, 27 | .hljs-template-tag, 28 | .hljs-template-variable, 29 | .hljs-type, 30 | .hljs-variable.language_ { 31 | /* prettylights-syntax-keyword */ 32 | color: #d73a49; 33 | } 34 | .hljs-title, 35 | .hljs-title.class_, 36 | .hljs-title.class_.inherited__, 37 | .hljs-title.function_ { 38 | /* prettylights-syntax-entity */ 39 | color: #6f42c1; 40 | } 41 | .hljs-attr, 42 | .hljs-attribute, 43 | .hljs-literal, 44 | .hljs-meta, 45 | .hljs-number, 46 | .hljs-operator, 47 | .hljs-variable, 48 | .hljs-selector-attr, 49 | .hljs-selector-class, 50 | .hljs-selector-id { 51 | /* prettylights-syntax-constant */ 52 | color: #005cc5; 53 | } 54 | .hljs-regexp, 55 | .hljs-string, 56 | .hljs-meta .hljs-string { 57 | /* prettylights-syntax-string */ 58 | color: #032f62; 59 | } 60 | .hljs-built_in, 61 | .hljs-symbol { 62 | /* prettylights-syntax-variable */ 63 | color: #e36209; 64 | } 65 | .hljs-comment, 66 | .hljs-code, 67 | .hljs-formula { 68 | /* prettylights-syntax-comment */ 69 | color: #6a737d; 70 | } 71 | .hljs-name, 72 | .hljs-quote, 73 | .hljs-selector-tag, 74 | .hljs-selector-pseudo { 75 | /* prettylights-syntax-entity-tag */ 76 | color: #22863a; 77 | } 78 | .hljs-subst { 79 | /* prettylights-syntax-storage-modifier-import */ 80 | color: #24292e; 81 | } 82 | .hljs-section { 83 | /* prettylights-syntax-markup-heading */ 84 | color: #005cc5; 85 | font-weight: bold; 86 | } 87 | .hljs-bullet { 88 | /* prettylights-syntax-markup-list */ 89 | color: #735c0f; 90 | } 91 | .hljs-emphasis { 92 | /* prettylights-syntax-markup-italic */ 93 | color: #24292e; 94 | font-style: italic; 95 | } 96 | .hljs-strong { 97 | /* prettylights-syntax-markup-bold */ 98 | color: #24292e; 99 | font-weight: bold; 100 | } 101 | .hljs-addition { 102 | /* prettylights-syntax-markup-inserted */ 103 | color: #22863a; 104 | background-color: #f0fff4; 105 | } 106 | .hljs-deletion { 107 | /* prettylights-syntax-markup-deleted */ 108 | color: #b31d28; 109 | background-color: #ffeef0; 110 | } 111 | .hljs-char.escape_, 112 | .hljs-link, 113 | .hljs-params, 114 | .hljs-property, 115 | .hljs-punctuation, 116 | .hljs-tag { 117 | /* purposely ignored */ 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /examples/chat-ui/src/styles/markdown.css: -------------------------------------------------------------------------------- 1 | /* Tighter markdown spacing */ 2 | .prose-tight { 3 | line-height: 1.5; 4 | } 5 | 6 | .prose-tight h1, 7 | .prose-tight h2, 8 | .prose-tight h3, 9 | .prose-tight h4, 10 | .prose-tight h5, 11 | .prose-tight h6 { 12 | margin-top: 1rem; 13 | margin-bottom: 0.5rem; 14 | } 15 | 16 | .prose-tight h1:first-child, 17 | .prose-tight h2:first-child, 18 | .prose-tight h3:first-child, 19 | .prose-tight h4:first-child, 20 | .prose-tight h5:first-child, 21 | .prose-tight h6:first-child { 22 | margin-top: 0; 23 | } 24 | 25 | .prose-tight p { 26 | margin-top: 0.75rem; 27 | margin-bottom: 0.75rem; 28 | } 29 | 30 | .prose-tight p:first-child { 31 | margin-top: 0; 32 | } 33 | 34 | .prose-tight p:last-child { 35 | margin-bottom: 0; 36 | } 37 | 38 | .prose-tight ul, 39 | .prose-tight ol { 40 | margin-top: 0.75rem; 41 | margin-bottom: 0.75rem; 42 | } 43 | 44 | .prose-tight li { 45 | margin-top: 0.25rem; 46 | margin-bottom: 0.25rem; 47 | } 48 | 49 | .prose-tight ul > li, 50 | .prose-tight ol > li { 51 | padding-left: 0.25rem; 52 | } 53 | 54 | .prose-tight blockquote { 55 | margin-top: 1rem; 56 | margin-bottom: 1rem; 57 | } 58 | 59 | .prose-tight code { 60 | font-size: 0.875em; 61 | } 62 | 63 | .prose-tight pre { 64 | margin-top: 1rem; 65 | margin-bottom: 1rem; 66 | } 67 | -------------------------------------------------------------------------------- /examples/chat-ui/src/styles/scrollbar.css: -------------------------------------------------------------------------------- 1 | /* Light theme scrollbar */ 2 | ::-webkit-scrollbar { 3 | width: 8px; 4 | height: 8px; 5 | /* Scrollbars are fuxing with the layout?? */ 6 | display: none; 7 | } 8 | 9 | ::-webkit-scrollbar-track { 10 | background: #f1f1f1; 11 | } 12 | 13 | ::-webkit-scrollbar-thumb { 14 | background: #888; 15 | border-radius: 4px; 16 | } 17 | 18 | ::-webkit-scrollbar-thumb:hover { 19 | background: #555; 20 | } 21 | 22 | /* Dark theme scrollbar */ 23 | .dark ::-webkit-scrollbar { 24 | width: 8px; 25 | height: 8px; 26 | } 27 | 28 | .dark ::-webkit-scrollbar-track { 29 | background: #27272a; /* zinc-800 */ 30 | } 31 | 32 | .dark ::-webkit-scrollbar-thumb { 33 | background: #52525b; /* zinc-600 */ 34 | border-radius: 4px; 35 | } 36 | 37 | .dark ::-webkit-scrollbar-thumb:hover { 38 | background: #71717a; /* zinc-500 */ 39 | } 40 | -------------------------------------------------------------------------------- /examples/chat-ui/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface BaseMessage { 2 | content: string 3 | } 4 | 5 | export interface UserMessage extends BaseMessage { 6 | role: 'user' 7 | } 8 | 9 | export interface SystemMessage extends BaseMessage { 10 | role: 'system' 11 | } 12 | 13 | export interface AssistantMessage extends BaseMessage { 14 | role: 'assistant' 15 | type: 'content' 16 | } 17 | 18 | export interface ReasoningMessage extends BaseMessage { 19 | role: 'assistant' 20 | type: 'reasoning' 21 | reasoningStartTime: number 22 | reasoningEndTime?: number 23 | isReasoningStreaming: boolean 24 | } 25 | 26 | export interface ToolCallMessage { 27 | role: 'tool-call' 28 | toolName: string 29 | toolArgs: Record<string, unknown> 30 | callId: string 31 | } 32 | 33 | export interface ToolResultMessage { 34 | role: 'tool-result' 35 | toolName: string 36 | toolArgs: Record<string, unknown> 37 | toolResult: any 38 | callId: string 39 | } 40 | 41 | export interface ErrorMessage { 42 | role: 'error' 43 | content: string 44 | timestamp: number 45 | } 46 | 47 | export type Message = UserMessage | SystemMessage | AssistantMessage | ReasoningMessage | ToolCallMessage | ToolResultMessage | ErrorMessage 48 | 49 | export interface Conversation { 50 | id?: number 51 | title: string 52 | messages: Message[] 53 | } 54 | 55 | export type Theme = 'light' | 'dark' | 'system' 56 | -------------------------------------------------------------------------------- /examples/chat-ui/src/types/models.ts: -------------------------------------------------------------------------------- 1 | // Types for models.dev API data 2 | export interface ModelData { 3 | id: string 4 | name: string 5 | attachment: boolean 6 | reasoning: boolean 7 | temperature: boolean 8 | tool_call: boolean 9 | knowledge: string 10 | release_date: string 11 | last_updated: string 12 | modalities: { 13 | input: string[] 14 | output: string[] 15 | } 16 | open_weights: boolean 17 | limit: { 18 | context: number 19 | output: number 20 | } 21 | cost?: { 22 | input: number 23 | output: number 24 | cache_read?: number 25 | cache_write?: number 26 | } 27 | } 28 | 29 | export type SupportedProvider = 'anthropic' | 'groq' | 'openrouter' 30 | 31 | export interface Provider { 32 | id: SupportedProvider 33 | name: string 34 | baseUrl: string 35 | logo: string 36 | documentationUrl: string 37 | authType: 'apiKey' | 'oauth' 38 | apiKeyHeader?: string 39 | oauth?: { 40 | authorizeUrl: string 41 | tokenUrl: string 42 | } 43 | } 44 | 45 | export interface Model { 46 | id: string 47 | name: string 48 | provider: Provider 49 | modelId: string 50 | supportsTools: boolean 51 | reasoning: boolean 52 | attachment: boolean 53 | contextLimit: number 54 | outputLimit: number 55 | cost?: { 56 | input: number 57 | output: number 58 | cache_read?: number 59 | cache_write?: number 60 | } 61 | } 62 | 63 | export const providers: Record<SupportedProvider, Provider> = { 64 | groq: { 65 | id: 'groq', 66 | name: 'Groq', 67 | baseUrl: 'https://api.groq.com/openai/v1', 68 | logo: '🚀', 69 | documentationUrl: 'https://console.groq.com/docs', 70 | authType: 'apiKey', 71 | apiKeyHeader: 'Authorization', 72 | // oauth: { 73 | // authorizeUrl: 'http://localhost:3000/keys/request', 74 | // tokenUrl: 'https://openrouter.ai/api/v1/auth/keys' 75 | // }, 76 | }, 77 | anthropic: { 78 | id: 'anthropic', 79 | name: 'Anthropic', 80 | baseUrl: 'https://api.anthropic.com/v1', 81 | logo: '🤖', 82 | documentationUrl: 'https://docs.anthropic.com/', 83 | authType: 'apiKey', 84 | apiKeyHeader: 'x-api-key', 85 | }, 86 | openrouter: { 87 | id: 'openrouter', 88 | name: 'OpenRouter', 89 | baseUrl: 'https://openrouter.ai/api/v1', 90 | logo: '🌐', 91 | documentationUrl: 'https://openrouter.ai/docs', 92 | authType: 'oauth', 93 | oauth: { 94 | authorizeUrl: 'https://openrouter.ai/auth', 95 | tokenUrl: 'https://openrouter.ai/api/v1/auth/keys', 96 | }, 97 | }, 98 | } 99 | 100 | export const SUPPORTED_PROVIDERS: readonly SupportedProvider[] = ['anthropic', 'groq', 'openrouter'] 101 | 102 | // Storage keys for user preferences 103 | export const FAVORITES_KEY = 'aiChatTemplate_favorites_v1' 104 | export const PROVIDER_TOKEN_KEY_PREFIX = 'aiChatTemplate_token_' 105 | -------------------------------------------------------------------------------- /examples/chat-ui/src/utils/apiKeys.ts: -------------------------------------------------------------------------------- 1 | // Re-export functions from new auth system for backward compatibility 2 | export { getApiKey, setApiKey, clearApiKey, hasApiKey } from './auth' 3 | -------------------------------------------------------------------------------- /examples/chat-ui/src/utils/debugLog.ts: -------------------------------------------------------------------------------- 1 | // Debug logging helper 2 | export const debugLog = (...args: any[]) => { 3 | if (typeof window !== 'undefined' && localStorage.getItem('USE_MCP_DEBUG') === 'true') { 4 | console.log(...args) 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /examples/chat-ui/src/utils/modelOptions.ts: -------------------------------------------------------------------------------- 1 | import { type SupportedProvider } from '../types/models' 2 | import modelsData from '../data/models.json' 3 | 4 | // Helper function to check if a model supports reasoning 5 | export function supportsReasoning(providerId: SupportedProvider, modelId: string): boolean { 6 | const providerModels = modelsData[providerId] as Record<string, any> 7 | if (!providerModels) return false 8 | 9 | const model = providerModels[modelId] 10 | return model?.reasoning === true 11 | } 12 | 13 | // Helper function to get provider-specific options 14 | export function getProviderOptions(providerId: SupportedProvider, modelId: string): Record<string, any> | undefined { 15 | // Add any provider-specific options here 16 | switch (providerId) { 17 | case 'groq': 18 | // Handle specific Groq model options - set reasoningFormat for all reasoning models 19 | if (supportsReasoning(providerId, modelId)) { 20 | return { 21 | groq: { 22 | reasoningFormat: 'parsed', 23 | }, 24 | } 25 | } 26 | break 27 | case 'anthropic': 28 | // Handle specific Anthropic model options 29 | break 30 | case 'openrouter': 31 | // Handle specific OpenRouter model options 32 | break 33 | } 34 | return undefined 35 | } 36 | -------------------------------------------------------------------------------- /examples/chat-ui/src/utils/modelPreferences.ts: -------------------------------------------------------------------------------- 1 | import { type Model, providers } from '../types/models' 2 | 3 | // Import models data directly 4 | import modelsData from '../data/models.json' 5 | 6 | const MODEL_PREFERENCE_KEY = 'aiChatTemplate_selectedModel' 7 | 8 | function getAvailableModels(): Model[] { 9 | const models: Model[] = [] 10 | for (const [providerId, providerModels] of Object.entries(modelsData)) { 11 | const provider = providers[providerId as keyof typeof providers] 12 | if (!provider) continue 13 | 14 | for (const [modelId, modelData] of Object.entries(providerModels)) { 15 | const model: Model = { 16 | id: `${providerId}:${modelId}`, 17 | name: modelData.name, 18 | provider, 19 | modelId, 20 | supportsTools: modelData.tool_call, 21 | reasoning: modelData.reasoning, 22 | attachment: modelData.attachment, 23 | contextLimit: modelData.limit.context, 24 | outputLimit: modelData.limit.output, 25 | cost: modelData.cost, 26 | } 27 | models.push(model) 28 | } 29 | } 30 | return models 31 | } 32 | 33 | export const getSelectedModel = (): Model => { 34 | const saved = localStorage.getItem(MODEL_PREFERENCE_KEY) 35 | if (saved) { 36 | try { 37 | const parsed = JSON.parse(saved) 38 | // Find the model by ID to ensure it still exists 39 | const availableModels = getAvailableModels() 40 | const model = availableModels.find((m) => m.id === parsed.id) 41 | if (model) { 42 | return model 43 | } 44 | } catch (e) { 45 | console.warn('Failed to parse saved model preference:', e) 46 | } 47 | } 48 | // Default to first available model 49 | const availableModels = getAvailableModels() 50 | return availableModels[0] 51 | } 52 | 53 | export const setSelectedModel = (model: Model): void => { 54 | localStorage.setItem(MODEL_PREFERENCE_KEY, JSON.stringify({ id: model.id })) 55 | } 56 | -------------------------------------------------------------------------------- /examples/chat-ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="vite/client" /> 2 | 3 | declare global { 4 | interface Window { 5 | apiKeyModalResolve?: (value: boolean) => void 6 | } 7 | } 8 | 9 | export {} 10 | -------------------------------------------------------------------------------- /examples/chat-ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | theme: { 4 | extend: { 5 | typography: { 6 | DEFAULT: { 7 | css: { 8 | pre: { 9 | padding: '0', 10 | filter: 'brightness(96%)', 11 | border: '0', 12 | backgroundColor: 'transparent', 13 | }, 14 | }, 15 | }, 16 | }, 17 | }, 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /examples/chat-ui/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 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 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /examples/chat-ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noUnusedLocals": false, 4 | "noUnusedParameters": false 5 | }, 6 | "files": [], 7 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }, { "path": "./tsconfig.worker.json" }] 8 | } 9 | -------------------------------------------------------------------------------- /examples/chat-ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /examples/chat-ui/tsconfig.worker.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.node.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.worker.tsbuildinfo", 5 | "types": ["@cloudflare/workers-types/2023-07-01", "vite/client"] 6 | }, 7 | "include": ["api"] 8 | } 9 | -------------------------------------------------------------------------------- /examples/chat-ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import { cloudflare } from '@cloudflare/vite-plugin' 4 | import tailwindcss from '@tailwindcss/vite' 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), tailwindcss(), cloudflare()], 9 | }) 10 | -------------------------------------------------------------------------------- /examples/chat-ui/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "node_modules/wrangler/config-schema.json", 7 | "name": "chat-example-use-mcp", 8 | "workers_dev": true, 9 | "routes": [ 10 | { 11 | "pattern": "chat.use-mcp.dev", 12 | "custom_domain": true, 13 | }, 14 | ], 15 | // "main": "api/index.ts", 16 | "compatibility_date": "2025-02-07", 17 | // "ai": { 18 | // "binding": "AI" 19 | // }, 20 | "assets": { 21 | "not_found_handling": "single-page-application", 22 | // "binding": "ASSETS", 23 | "directory": "dist/client", 24 | }, 25 | "observability": { 26 | "enabled": true, 27 | }, 28 | /** 29 | * Smart Placement 30 | * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 31 | */ 32 | // "placement": { "mode": "smart" }, 33 | 34 | /** 35 | * Bindings 36 | * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including 37 | * databases, object storage, AI inference, real-time communication and more. 38 | * https://developers.cloudflare.com/workers/runtime-apis/bindings/ 39 | */ 40 | 41 | /** 42 | * Environment Variables 43 | * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 44 | */ 45 | // "vars": { "MY_VARIABLE": "production_value" }, 46 | /** 47 | * Note: Use secrets to store sensitive data. 48 | * https://developers.cloudflare.com/workers/configuration/secrets/ 49 | */ 50 | 51 | /** 52 | * Static Assets 53 | * https://developers.cloudflare.com/workers/static-assets/binding/ 54 | */ 55 | // "assets": { "directory": "./public/", "binding": "ASSETS" }, 56 | 57 | /** 58 | * Service Bindings (communicate between multiple Workers) 59 | * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 60 | */ 61 | // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] 62 | } 63 | -------------------------------------------------------------------------------- /examples/inspector/.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 | 26 | .wrangler 27 | 28 | # Playwright test results 29 | test-results/ 30 | playwright-report/ -------------------------------------------------------------------------------- /examples/inspector/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true, 6 | "printWidth": 140 7 | } 8 | -------------------------------------------------------------------------------- /examples/inspector/AGENT.md: -------------------------------------------------------------------------------- 1 | # AI Chat Template - Development Guide 2 | 3 | ## Commands 4 | - **Dev server**: `pnpm dev` 5 | - **Build**: `pnpm build` (runs TypeScript compilation then Vite build) 6 | - **Lint**: `pnpm lint` (ESLint) 7 | - **Deploy**: `pnpm deploy` (builds and deploys with Wrangler) 8 | - **Test**: `pnpm test` (Playwright E2E tests) 9 | - **Test UI**: `pnpm test:ui` (Playwright test runner with UI) 10 | - **Test headed**: `pnpm test:headed` (Run tests in visible browser) 11 | 12 | ## Code Style 13 | - **Formatting**: Prettier with 2-space tabs, single quotes, no semicolons, 140 char line width 14 | - **Imports**: Use `.tsx`/`.ts` extensions in imports, group by external/internal 15 | - **Components**: React FC with explicit typing, PascalCase names 16 | - **Hooks**: Custom hooks start with `use`, camelCase 17 | - **Types**: Define interfaces in `src/types/index.ts`, use `type` for unions 18 | - **Files**: Use PascalCase for components, camelCase for hooks/utilities 19 | - **State**: Use proper TypeScript typing for all state variables 20 | - **Error handling**: Use try/catch blocks with proper error propagation 21 | - **Database**: IndexedDB with typed interfaces, async/await pattern 22 | - **Styling**: Tailwind CSS classes, responsive design patterns 23 | 24 | ## Tech Stack 25 | React 19, TypeScript, Vite, Tailwind CSS, Hono API, Cloudflare Workers, IndexedDB 26 | -------------------------------------------------------------------------------- /examples/inspector/README.md: -------------------------------------------------------------------------------- 1 | # MCP Inspector 2 | 3 | A minimal demo showcasing the `use-mcp` React hook for connecting to Model Context Protocol (MCP) servers. 4 | 5 | ## Features 6 | 7 | - Connect to any MCP server via URL 8 | - View available tools and their schemas 9 | - Browse and read server resources 10 | - Interact with server-provided prompts 11 | - Real-time connection status monitoring 12 | - Debug logging for troubleshooting 13 | - Clean, minimal UI focused on MCP functionality 14 | 15 | ## Getting Started 16 | 17 | 0. Make sure you've built the parent `use-mcp` directory at least once! 18 | ```bash 19 | cd ../.. && pnpm build && cd - 20 | ``` 21 | 22 | Alternatively, run `pnpm dev` in the parent directory in a second terminal if you want to iterate on both the library and the example together. 23 | 24 | 1. Install dependencies: 25 | ```bash 26 | pnpm install 27 | ``` 28 | 29 | 2. Start the development server: 30 | ```bash 31 | pnpm dev 32 | ``` 33 | 34 | 3. Open your browser and navigate to the displayed local URL 35 | 36 | 4. Enter an MCP server URL to test the connection and explore available tools, resources, and prompts 37 | 38 | ## What This Demonstrates 39 | 40 | This example shows how easy it is to integrate MCP servers into a React application using the `use-mcp` hook. The core functionality is just: 41 | 42 | ```tsx 43 | import { useMcp } from 'use-mcp/react' 44 | 45 | const connection = useMcp({ 46 | url: 'your-mcp-server-url', 47 | debug: true, 48 | autoRetry: false 49 | }) 50 | 51 | // Access connection.state, connection.tools, connection.resources, 52 | // connection.prompts, connection.error, etc. 53 | ``` 54 | 55 | The `McpServers` component wraps this hook to provide a complete UI for server management, tool inspection, resource browsing, and prompt interaction. 56 | 57 | ## Supported MCP Features 58 | 59 | - **Tools**: Execute server-provided tools with custom arguments and view results 60 | - **Resources**: Browse available resources and read their contents (text or binary) 61 | - **Resource Templates**: View dynamic resource templates with URI patterns 62 | - **Prompts**: Interact with server prompts, provide arguments, and view generated messages 63 | 64 | Note: Not all MCP servers implement all features. The inspector will gracefully handle servers that only support a subset of the MCP specification. 65 | -------------------------------------------------------------------------------- /examples/inspector/eslint.config.js: -------------------------------------------------------------------------------- 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.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 23 | }, 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /examples/inspector/index.html: -------------------------------------------------------------------------------- 1 | <!doctype html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8" /> 5 | <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> 6 | <link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" /> 7 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 8 | 9 | <!-- Primary Meta Tags --> 10 | <title>MCP Inspector - use-mcp React Hook Demo</title> 11 | <meta 12 | name="description" 13 | content="Interactive demo of the use-mcp React hook for Model Context Protocol (MCP) servers. Connect to MCP servers, explore available tools, and execute commands using the use-mcp library." 14 | /> 15 | 16 | <!-- Open Graph / Facebook --> 17 | <meta property="og:type" content="website" /> 18 | <meta property="og:site_name" content="MCP Inspector" /> 19 | <meta property="og:title" content="MCP Inspector - use-mcp React Hook Demo" /> 20 | <meta 21 | property="og:description" 22 | content="Interactive demo of the use-mcp React hook for Model Context Protocol (MCP) servers. Connect to MCP servers, explore available tools, and execute commands using the use-mcp library." 23 | /> 24 | <meta property="og:image" content="https://inspector.use-mcp.dev/use-mcp-card.png" /> 25 | <meta property="og:image:width" content="1200" /> 26 | <meta property="og:image:height" content="630" /> 27 | <meta property="og:url" content="https://inspector.use-mcp.dev" /> 28 | 29 | <!-- Twitter --> 30 | <meta name="twitter:card" content="summary_large_image" /> 31 | <meta name="twitter:title" content="MCP Inspector - use-mcp React Hook Demo" /> 32 | <meta 33 | name="twitter:description" 34 | content="Interactive demo of the use-mcp React hook for Model Context Protocol (MCP) servers. Connect to MCP servers, explore available tools, and execute commands using the use-mcp library." 35 | /> 36 | <meta name="twitter:image" content="https://inspector.use-mcp.dev/use-mcp-card.png" /> 37 | 38 | <!-- Additional Meta Tags --> 39 | <meta name="author" content="Glen Maddern" /> 40 | <meta name="keywords" content="use-mcp, Model Context Protocol, MCP, AI, LLM, tools, inspector, debugging, development, React hook" /> 41 | <meta name="application-name" content="MCP Inspector" /> 42 | <meta name="apple-mobile-web-app-title" content="MCP Inspector" /> 43 | <meta name="theme-color" content="#ffffff" /> 44 | 45 | <!-- Canonical URL --> 46 | <link rel="canonical" href="https://inspector.use-mcp.dev" /> 47 | </head> 48 | <body> 49 | <div id="root"></div> 50 | <script type="module" src="/src/main.tsx"></script> 51 | </body> 52 | </html> 53 | -------------------------------------------------------------------------------- /examples/inspector/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mcp-inspector", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --port=5001", 8 | "build": "tsc -b && pnpm run build:only", 9 | "build:only": "vite build", 10 | "lint": "eslint .", 11 | "preview": "vite preview", 12 | "deploy": "pnpm run build:only && wrangler deploy" 13 | }, 14 | "dependencies": { 15 | "@tailwindcss/vite": "^4.0.14", 16 | "lucide-react": "^0.482.0", 17 | "react": "^19.0.0", 18 | "react-dom": "^19.0.0", 19 | "react-router-dom": "^7.6.2", 20 | "tailwindcss": "^4.0.14" 21 | }, 22 | "devDependencies": { 23 | "@cloudflare/vite-plugin": "^1.5.0", 24 | "@eslint/js": "^9.21.0", 25 | "@tailwindcss/typography": "^0.5.16", 26 | "@types/react": "^19.0.10", 27 | "@types/react-dom": "^19.0.4", 28 | "@vitejs/plugin-react": "^4.3.4", 29 | "eslint": "^9.21.0", 30 | "eslint-plugin-react-hooks": "^5.1.0", 31 | "eslint-plugin-react-refresh": "^0.4.19", 32 | "globals": "^15.15.0", 33 | "prettier": "^3.5.3", 34 | "typescript": "~5.7.2", 35 | "typescript-eslint": "^8.24.1", 36 | "use-mcp": "link:../..", 37 | "vite": "^6.2.0", 38 | "wrangler": "^4.19.1" 39 | }, 40 | "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" 41 | } 42 | -------------------------------------------------------------------------------- /examples/inspector/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> -------------------------------------------------------------------------------- /examples/inspector/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' 2 | import { McpServers } from './components/McpServers.js' 3 | import { OAuthCallback } from './components/OAuthCallback.js' 4 | 5 | function App() { 6 | return ( 7 | <Router> 8 | <Routes> 9 | <Route path="/oauth/callback" element={<OAuthCallback />} /> 10 | <Route 11 | path="/" 12 | element={ 13 | <div className="min-h-screen bg-gray-50 flex items-center justify-center p-4"> 14 | <div className="w-full max-w-2xl"> 15 | <div className="text-center mb-8"> 16 | <h1 className="text-3xl font-bold text-gray-900 mb-2">MCP Inspector</h1> 17 | <p className="text-gray-600"> 18 | Minimal demo showcasing the{' '} 19 | <a 20 | href="https://github.com/modelcontextprotocol/use-mcp" 21 | target="_blank" 22 | rel="noopener noreferrer" 23 | className="text-blue-600 hover:text-blue-800 underline font-medium transition-colors" 24 | > 25 | use-mcp 26 | </a>{' '} 27 | React hook 28 | </p> 29 | </div> 30 | <McpServers /> 31 | </div> 32 | </div> 33 | } 34 | /> 35 | </Routes> 36 | </Router> 37 | ) 38 | } 39 | 40 | export default App 41 | -------------------------------------------------------------------------------- /examples/inspector/src/components/OAuthCallback.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { onMcpAuthorization } from 'use-mcp' 3 | 4 | export function OAuthCallback() { 5 | useEffect(() => { 6 | onMcpAuthorization() 7 | }, []) 8 | 9 | return ( 10 | <div className="min-h-screen bg-gray-50 flex items-center justify-center p-4"> 11 | <div className="text-center"> 12 | <h1 className="text-2xl font-bold text-gray-900 mb-4">Authenticating...</h1> 13 | <p className="text-gray-600 mb-2">Please wait while we complete your authentication.</p> 14 | <p className="text-sm text-gray-500">This window should close automatically.</p> 15 | </div> 16 | </div> 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /examples/inspector/src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @plugin "@tailwindcss/typography"; 3 | @config "../tailwind.config.js"; 4 | -------------------------------------------------------------------------------- /examples/inspector/src/main.tsx: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /examples/inspector/src/styles/github.css: -------------------------------------------------------------------------------- 1 | .light { 2 | pre code.hljs { 3 | display: block; 4 | overflow-x: auto; 5 | padding: 1em; 6 | } 7 | code.hljs { 8 | padding: 3px 5px; 9 | } 10 | /*! 11 | Theme: GitHub 12 | Description: Light theme as seen on github.com 13 | Author: github.com 14 | Maintainer: @Hirse 15 | Updated: 2021-05-15 16 | 17 | Outdated base version: https://github.com/primer/github-syntax-light 18 | Current colors taken from GitHub's CSS 19 | */ 20 | .hljs { 21 | color: #24292e; 22 | background: #ffffff; 23 | } 24 | .hljs-doctag, 25 | .hljs-keyword, 26 | .hljs-meta .hljs-keyword, 27 | .hljs-template-tag, 28 | .hljs-template-variable, 29 | .hljs-type, 30 | .hljs-variable.language_ { 31 | /* prettylights-syntax-keyword */ 32 | color: #d73a49; 33 | } 34 | .hljs-title, 35 | .hljs-title.class_, 36 | .hljs-title.class_.inherited__, 37 | .hljs-title.function_ { 38 | /* prettylights-syntax-entity */ 39 | color: #6f42c1; 40 | } 41 | .hljs-attr, 42 | .hljs-attribute, 43 | .hljs-literal, 44 | .hljs-meta, 45 | .hljs-number, 46 | .hljs-operator, 47 | .hljs-variable, 48 | .hljs-selector-attr, 49 | .hljs-selector-class, 50 | .hljs-selector-id { 51 | /* prettylights-syntax-constant */ 52 | color: #005cc5; 53 | } 54 | .hljs-regexp, 55 | .hljs-string, 56 | .hljs-meta .hljs-string { 57 | /* prettylights-syntax-string */ 58 | color: #032f62; 59 | } 60 | .hljs-built_in, 61 | .hljs-symbol { 62 | /* prettylights-syntax-variable */ 63 | color: #e36209; 64 | } 65 | .hljs-comment, 66 | .hljs-code, 67 | .hljs-formula { 68 | /* prettylights-syntax-comment */ 69 | color: #6a737d; 70 | } 71 | .hljs-name, 72 | .hljs-quote, 73 | .hljs-selector-tag, 74 | .hljs-selector-pseudo { 75 | /* prettylights-syntax-entity-tag */ 76 | color: #22863a; 77 | } 78 | .hljs-subst { 79 | /* prettylights-syntax-storage-modifier-import */ 80 | color: #24292e; 81 | } 82 | .hljs-section { 83 | /* prettylights-syntax-markup-heading */ 84 | color: #005cc5; 85 | font-weight: bold; 86 | } 87 | .hljs-bullet { 88 | /* prettylights-syntax-markup-list */ 89 | color: #735c0f; 90 | } 91 | .hljs-emphasis { 92 | /* prettylights-syntax-markup-italic */ 93 | color: #24292e; 94 | font-style: italic; 95 | } 96 | .hljs-strong { 97 | /* prettylights-syntax-markup-bold */ 98 | color: #24292e; 99 | font-weight: bold; 100 | } 101 | .hljs-addition { 102 | /* prettylights-syntax-markup-inserted */ 103 | color: #22863a; 104 | background-color: #f0fff4; 105 | } 106 | .hljs-deletion { 107 | /* prettylights-syntax-markup-deleted */ 108 | color: #b31d28; 109 | background-color: #ffeef0; 110 | } 111 | .hljs-char.escape_, 112 | .hljs-link, 113 | .hljs-params, 114 | .hljs-property, 115 | .hljs-punctuation, 116 | .hljs-tag { 117 | /* purposely ignored */ 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /examples/inspector/src/styles/scrollbar.css: -------------------------------------------------------------------------------- 1 | /* Light theme scrollbar */ 2 | ::-webkit-scrollbar { 3 | width: 8px; 4 | height: 8px; 5 | } 6 | 7 | ::-webkit-scrollbar-track { 8 | background: #f1f1f1; 9 | } 10 | 11 | ::-webkit-scrollbar-thumb { 12 | background: #888; 13 | border-radius: 4px; 14 | } 15 | 16 | ::-webkit-scrollbar-thumb:hover { 17 | background: #555; 18 | } 19 | 20 | /* Dark theme scrollbar */ 21 | .dark ::-webkit-scrollbar { 22 | width: 8px; 23 | height: 8px; 24 | } 25 | 26 | .dark ::-webkit-scrollbar-track { 27 | background: #27272a; /* zinc-800 */ 28 | } 29 | 30 | .dark ::-webkit-scrollbar-thumb { 31 | background: #52525b; /* zinc-600 */ 32 | border-radius: 4px; 33 | } 34 | 35 | .dark ::-webkit-scrollbar-thumb:hover { 36 | background: #71717a; /* zinc-500 */ 37 | } 38 | -------------------------------------------------------------------------------- /examples/inspector/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="vite/client" /> 2 | 3 | declare global { 4 | interface Window { 5 | apiKeyModalResolve?: (value: boolean) => void 6 | } 7 | } 8 | 9 | export {} 10 | -------------------------------------------------------------------------------- /examples/inspector/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | theme: { 4 | extend: { 5 | typography: { 6 | DEFAULT: { 7 | css: { 8 | pre: { 9 | padding: '0', 10 | filter: 'brightness(96%)', 11 | border: '0', 12 | backgroundColor: 'transparent', 13 | }, 14 | }, 15 | }, 16 | }, 17 | }, 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /examples/inspector/tsconfig.app.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 | 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 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /examples/inspector/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noUnusedLocals": false, 4 | "noUnusedParameters": false 5 | }, 6 | "files": [], 7 | "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] 8 | } 9 | -------------------------------------------------------------------------------- /examples/inspector/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /examples/inspector/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import tailwindcss from '@tailwindcss/vite' 4 | 5 | // https://vite.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), tailwindcss()], 8 | build: { 9 | // @ts-expect-error I don't want to install @types/node for this one line 10 | minify: process.env.NO_MINIFY !== 'true', 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /examples/inspector/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "node_modules/wrangler/config-schema.json", 7 | "name": "inspector-use-mcp", 8 | "routes": [ 9 | { 10 | "pattern": "inspector.use-mcp.dev", 11 | "custom_domain": true, 12 | }, 13 | ], 14 | "workers_dev": true, 15 | // "main": "api/index.ts", 16 | "compatibility_date": "2025-02-07", 17 | // "ai": { 18 | // "binding": "AI" 19 | // }, 20 | "assets": { 21 | "not_found_handling": "single-page-application", 22 | "directory": "dist", 23 | }, 24 | "observability": { 25 | "enabled": true, 26 | }, 27 | /** 28 | * Smart Placement 29 | * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 30 | */ 31 | // "placement": { "mode": "smart" }, 32 | /** 33 | * Bindings 34 | * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including 35 | * databases, object storage, AI inference, real-time communication and more. 36 | * https://developers.cloudflare.com/workers/runtime-apis/bindings/ 37 | */ 38 | /** 39 | * Environment Variables 40 | * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 41 | */ 42 | // "vars": { "MY_VARIABLE": "production_value" }, 43 | /** 44 | * Note: Use secrets to store sensitive data. 45 | * https://developers.cloudflare.com/workers/configuration/secrets/ 46 | */ 47 | /** 48 | * Static Assets 49 | * https://developers.cloudflare.com/workers/static-assets/binding/ 50 | */ 51 | // "assets": { "directory": "./public/", "binding": "ASSETS" }, 52 | /** 53 | * Service Bindings (communicate between multiple Workers) 54 | * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 55 | */ 56 | // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] 57 | } 58 | -------------------------------------------------------------------------------- /examples/servers/cf-agents/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | logs 4 | _.log 5 | npm-debug.log_ 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | .pnpm-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | 13 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 | 15 | # Runtime data 16 | 17 | pids 18 | _.pid 19 | _.seed 20 | \*.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | 28 | coverage 29 | \*.lcov 30 | 31 | # nyc test coverage 32 | 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | 41 | bower_components 42 | 43 | # node-waf configuration 44 | 45 | .lock-wscript 46 | 47 | # Compiled binary addons (https://nodejs.org/api/addons.html) 48 | 49 | build/Release 50 | 51 | # Dependency directories 52 | 53 | node_modules/ 54 | jspm_packages/ 55 | 56 | # Snowpack dependency directory (https://snowpack.dev/) 57 | 58 | web_modules/ 59 | 60 | # TypeScript cache 61 | 62 | \*.tsbuildinfo 63 | 64 | # Optional npm cache directory 65 | 66 | .npm 67 | 68 | # Optional eslint cache 69 | 70 | .eslintcache 71 | 72 | # Optional stylelint cache 73 | 74 | .stylelintcache 75 | 76 | # Microbundle cache 77 | 78 | .rpt2_cache/ 79 | .rts2_cache_cjs/ 80 | .rts2_cache_es/ 81 | .rts2_cache_umd/ 82 | 83 | # Optional REPL history 84 | 85 | .node_repl_history 86 | 87 | # Output of 'npm pack' 88 | 89 | \*.tgz 90 | 91 | # Yarn Integrity file 92 | 93 | .yarn-integrity 94 | 95 | # dotenv environment variable files 96 | 97 | .env 98 | .env.development.local 99 | .env.test.local 100 | .env.production.local 101 | .env.local 102 | 103 | # parcel-bundler cache (https://parceljs.org/) 104 | 105 | .cache 106 | .parcel-cache 107 | 108 | # Next.js build output 109 | 110 | .next 111 | out 112 | 113 | # Nuxt.js build / generate output 114 | 115 | .nuxt 116 | dist 117 | 118 | # Gatsby files 119 | 120 | .cache/ 121 | 122 | # Comment in the public line in if your project uses Gatsby and not Next.js 123 | 124 | # https://nextjs.org/blog/next-9-1#public-directory-support 125 | 126 | # public 127 | 128 | # vuepress build output 129 | 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | 134 | .temp 135 | .cache 136 | 137 | # Docusaurus cache and generated files 138 | 139 | .docusaurus 140 | 141 | # Serverless directories 142 | 143 | .serverless/ 144 | 145 | # FuseBox cache 146 | 147 | .fusebox/ 148 | 149 | # DynamoDB Local files 150 | 151 | .dynamodb/ 152 | 153 | # TernJS port file 154 | 155 | .tern-port 156 | 157 | # Stores VSCode versions used for testing VSCode extensions 158 | 159 | .vscode-test 160 | 161 | # yarn v2 162 | 163 | .yarn/cache 164 | .yarn/unplugged 165 | .yarn/build-state.yml 166 | .yarn/install-state.gz 167 | .pnp.\* 168 | 169 | # wrangler project 170 | 171 | .dev.vars 172 | .wrangler/ 173 | -------------------------------------------------------------------------------- /examples/servers/cf-agents/README.md: -------------------------------------------------------------------------------- 1 | # Building a Remote MCP Server on Cloudflare (Without Auth) 2 | 3 | This example allows you to deploy a remote MCP server that doesn't require authentication on Cloudflare Workers. 4 | 5 | ## Get started: 6 | 7 | [](https://deploy.workers.cloudflare.com/?url=https://github.com/cloudflare/ai/tree/main/demos/remote-mcp-authless) 8 | 9 | This will deploy your MCP server to a URL like: `remote-mcp-server-authless.<your-account>.workers.dev/sse` 10 | 11 | Alternatively, you can use the command line below to get the remote MCP Server created on your local machine: 12 | ```bash 13 | npm create cloudflare@latest -- my-mcp-server --template=cloudflare/ai/demos/remote-mcp-authless 14 | ``` 15 | 16 | ## Customizing your MCP Server 17 | 18 | To add your own [tools](https://developers.cloudflare.com/agents/model-context-protocol/tools/) to the MCP server, define each tool inside the `init()` method of `src/index.ts` using `this.server.tool(...)`. 19 | 20 | ## Connect to Cloudflare AI Playground 21 | 22 | You can connect to your MCP server from the Cloudflare AI Playground, which is a remote MCP client: 23 | 24 | 1. Go to https://playground.ai.cloudflare.com/ 25 | 2. Enter your deployed MCP server URL (`remote-mcp-server-authless.<your-account>.workers.dev/sse`) 26 | 3. You can now use your MCP tools directly from the playground! 27 | 28 | ## Connect Claude Desktop to your MCP server 29 | 30 | You can also connect to your remote MCP server from local MCP clients, by using the [mcp-remote proxy](https://www.npmjs.com/package/mcp-remote). 31 | 32 | To connect to your MCP server from Claude Desktop, follow [Anthropic's Quickstart](https://modelcontextprotocol.io/quickstart/user) and within Claude Desktop go to Settings > Developer > Edit Config. 33 | 34 | Update with this configuration: 35 | 36 | ```json 37 | { 38 | "mcpServers": { 39 | "calculator": { 40 | "command": "npx", 41 | "args": [ 42 | "mcp-remote", 43 | "http://localhost:8787/sse" // or remote-mcp-server-authless.your-account.workers.dev/sse 44 | ] 45 | } 46 | } 47 | } 48 | ``` 49 | 50 | Restart Claude and you should see the tools become available. 51 | -------------------------------------------------------------------------------- /examples/servers/cf-agents/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.0.4/schema.json", 3 | "assist": { 4 | "actions": { 5 | "source": { 6 | "useSortedKeys": "on" 7 | } 8 | }, 9 | "enabled": true 10 | }, 11 | "files": { 12 | "includes": ["!worker-configuration.d.ts"] 13 | }, 14 | "formatter": { 15 | "enabled": true, 16 | "indentWidth": 4, 17 | "lineWidth": 100 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true, 23 | "style": { 24 | "noInferrableTypes": "error", 25 | "noNonNullAssertion": "off", 26 | "noParameterAssign": "error", 27 | "noUnusedTemplateLiteral": "error", 28 | "noUselessElse": "error", 29 | "useAsConstAssertion": "error", 30 | "useDefaultParameterLast": "error", 31 | "useEnumInitializers": "error", 32 | "useNumberNamespace": "error", 33 | "useSelfClosingElements": "error", 34 | "useSingleVarDeclarator": "error" 35 | }, 36 | "suspicious": { 37 | "noConfusingVoidType": "off", 38 | "noDebugger": "off", 39 | "noExplicitAny": "off" 40 | } 41 | } 42 | }, 43 | "root": false, 44 | "vcs": { 45 | "clientKind": "git", 46 | "enabled": true, 47 | "useIgnoreFile": true 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /examples/servers/cf-agents/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remote-mcp-server-authless", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "deploy": "wrangler deploy", 7 | "dev": "wrangler dev --port 5102", 8 | "format": "biome format --write", 9 | "lint:fix": "biome lint --fix", 10 | "start": "wrangler dev", 11 | "cf-typegen": "wrangler types", 12 | "type-check": "tsc --noEmit" 13 | }, 14 | "dependencies": { 15 | "@modelcontextprotocol/sdk": "^1.13.3", 16 | "zod": "^3.25.67" 17 | }, 18 | "devDependencies": { 19 | "@biomejs/biome": "^2.0.4", 20 | "@cloudflare/workers-oauth-provider": "^0.0.5", 21 | "@types/node": "^24.0.7", 22 | "agents": "^0.0.100", 23 | "hono": "^4.8.3", 24 | "typescript": "^5.8.3", 25 | "wrangler": "^4.23.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/servers/cf-agents/src/index.ts: -------------------------------------------------------------------------------- 1 | import { McpAgent } from 'agents/mcp' 2 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' 3 | import { z } from 'zod' 4 | import OAuthProvider, { type OAuthHelpers } from '@cloudflare/workers-oauth-provider' 5 | import { Hono } from 'hono' 6 | 7 | // Define our MCP agent with tools 8 | export class MyMCP extends McpAgent { 9 | server = new McpServer({ 10 | name: 'Authless Calculator', 11 | version: '1.0.0', 12 | }) 13 | 14 | async init() { 15 | // Simple addition tool 16 | this.server.tool('add', { a: z.number(), b: z.number() }, async ({ a, b }) => ({ 17 | content: [{ type: 'text', text: String(a + b) }], 18 | })) 19 | 20 | // Calculator tool with multiple operations 21 | this.server.tool( 22 | 'calculate', 23 | { 24 | operation: z.enum(['add', 'subtract', 'multiply', 'divide']), 25 | a: z.number(), 26 | b: z.number(), 27 | }, 28 | async ({ operation, a, b }) => { 29 | let result: number 30 | switch (operation) { 31 | case 'add': 32 | result = a + b 33 | break 34 | case 'subtract': 35 | result = a - b 36 | break 37 | case 'multiply': 38 | result = a * b 39 | break 40 | case 'divide': 41 | if (b === 0) 42 | return { 43 | content: [ 44 | { 45 | type: 'text', 46 | text: 'Error: Cannot divide by zero', 47 | }, 48 | ], 49 | } 50 | result = a / b 51 | break 52 | } 53 | return { content: [{ type: 'text', text: String(result) }] } 54 | }, 55 | ) 56 | 57 | // Register sample resources using the agents framework API 58 | this.server.resource('calc://history', 'Calculation History', async () => ({ 59 | contents: [ 60 | { 61 | uri: 'calc://history', 62 | mimeType: 'application/json', 63 | text: JSON.stringify( 64 | { 65 | calculations: [ 66 | { operation: 'add', a: 5, b: 3, result: 8, timestamp: '2024-01-01T10:00:00Z' }, 67 | { operation: 'multiply', a: 4, b: 7, result: 28, timestamp: '2024-01-01T10:01:00Z' }, 68 | ], 69 | }, 70 | null, 71 | 2, 72 | ), 73 | }, 74 | ], 75 | })) 76 | 77 | this.server.resource('calc://settings', 'Calculator Settings', async () => ({ 78 | contents: [ 79 | { 80 | uri: 'calc://settings', 81 | mimeType: 'application/json', 82 | text: JSON.stringify( 83 | { 84 | precision: 2, 85 | allowNegative: true, 86 | maxValue: 1000000, 87 | }, 88 | null, 89 | 2, 90 | ), 91 | }, 92 | ], 93 | })) 94 | 95 | // Register a dynamic resource 96 | this.server.resource('calc://stats', 'Calculation Statistics', async () => ({ 97 | contents: [ 98 | { 99 | uri: 'calc://stats', 100 | mimeType: 'application/json', 101 | text: JSON.stringify( 102 | { 103 | totalCalculations: Math.floor(Math.random() * 100), 104 | lastUpdated: new Date().toISOString(), 105 | }, 106 | null, 107 | 2, 108 | ), 109 | }, 110 | ], 111 | })) 112 | 113 | // Register sample prompts using the agents framework API 114 | this.server.prompt( 115 | 'math_problem', 116 | 'Generate a math problem', 117 | { 118 | difficulty: z.string(), 119 | topic: z.string().optional(), 120 | }, 121 | async ({ difficulty, topic }) => ({ 122 | messages: [ 123 | { 124 | role: 'user', 125 | content: { 126 | type: 'text', 127 | text: `Generate a ${difficulty} math problem${topic ? ` about ${topic}` : ''}`, 128 | }, 129 | }, 130 | ], 131 | }), 132 | ) 133 | 134 | this.server.prompt('explain_calculation', 'Explain a calculation', { operation: z.string() }, async ({ operation }) => ({ 135 | messages: [ 136 | { 137 | role: 'user', 138 | content: { 139 | type: 'text', 140 | text: `Please explain how to perform ${operation} step by step with examples`, 141 | }, 142 | }, 143 | ], 144 | })) 145 | } 146 | } 147 | 148 | export type Bindings = Env & { 149 | OAUTH_PROVIDER: OAuthHelpers 150 | } 151 | 152 | const app = new Hono<{ 153 | Bindings: Bindings 154 | }>() 155 | 156 | app.get('/authorize', async (c) => { 157 | const oauthReqInfo = await c.env.OAUTH_PROVIDER.parseAuthRequest(c.req.raw) 158 | const email = 'example@dotcom.com' 159 | const { redirectTo } = await c.env.OAUTH_PROVIDER.completeAuthorization({ 160 | request: oauthReqInfo, 161 | userId: email, 162 | metadata: { 163 | label: 'Test User', 164 | }, 165 | scope: oauthReqInfo.scope, 166 | props: { 167 | userEmail: email, 168 | }, 169 | }) 170 | return Response.redirect(redirectTo) 171 | }) 172 | 173 | export default { 174 | fetch(request: Request, env: Env, ctx: ExecutionContext) { 175 | const url = new URL(request.url) 176 | 177 | if (url.pathname === '/public/sse' || url.pathname === '/public/sse/message') { 178 | return MyMCP.serveSSE('/public/sse').fetch(request, env, ctx) 179 | } 180 | 181 | if (url.pathname === '/public/mcp') { 182 | return MyMCP.serve('/public/mcp').fetch(request, env, ctx) 183 | } 184 | 185 | return new OAuthProvider({ 186 | apiRoute: ['/sse', '/mcp'], 187 | apiHandler: { 188 | // @ts-ignore 189 | fetch: (request, env, ctx) => { 190 | const { pathname } = new URL(request.url) 191 | if (pathname.startsWith('/sse')) return MyMCP.serveSSE('/sse').fetch(request as any, env, ctx) 192 | if (pathname === '/mcp') return MyMCP.serve('/mcp').fetch(request as any, env, ctx) 193 | return new Response('Not found', { status: 404 }) 194 | }, 195 | }, 196 | // @ts-ignore 197 | defaultHandler: app, 198 | authorizeEndpoint: '/authorize', 199 | tokenEndpoint: '/token', 200 | clientRegistrationEndpoint: '/register', 201 | }).fetch(request, env, ctx) 202 | }, 203 | } 204 | -------------------------------------------------------------------------------- /examples/servers/cf-agents/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2021", 4 | "lib": ["es2021"], 5 | "jsx": "react-jsx", 6 | "module": "es2022", 7 | "moduleResolution": "bundler", 8 | "resolveJsonModule": true, 9 | "allowJs": true, 10 | "checkJs": false, 11 | "noEmit": true, 12 | "isolatedModules": true, 13 | "allowSyntheticDefaultImports": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "strict": true, 16 | "skipLibCheck": true, 17 | "types": ["./worker-configuration.d.ts", "node"] 18 | }, 19 | "include": ["worker-configuration.d.ts", "src/**/*.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /examples/servers/cf-agents/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "node_modules/wrangler/config-schema.json", 7 | "name": "cf-agents", 8 | "main": "src/index.ts", 9 | "compatibility_date": "2025-03-10", 10 | "compatibility_flags": ["nodejs_compat"], 11 | "migrations": [ 12 | { 13 | "new_sqlite_classes": ["MyMCP"], 14 | "tag": "v1" 15 | } 16 | ], 17 | "durable_objects": { 18 | "bindings": [ 19 | { 20 | "class_name": "MyMCP", 21 | "name": "MCP_OBJECT" 22 | } 23 | ] 24 | }, 25 | "observability": { 26 | "enabled": true 27 | }, 28 | "kv_namespaces": [ 29 | { 30 | "binding": "OAUTH_KV", 31 | "id": "<Add-KV-ID>" 32 | } 33 | ] 34 | /** 35 | * Smart Placement 36 | * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 37 | */ 38 | // "placement": { "mode": "smart" }, 39 | 40 | /** 41 | * Bindings 42 | * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including 43 | * databases, object storage, AI inference, real-time communication and more. 44 | * https://developers.cloudflare.com/workers/runtime-apis/bindings/ 45 | */ 46 | 47 | /** 48 | * Environment Variables 49 | * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 50 | */ 51 | // "vars": { "MY_VARIABLE": "production_value" }, 52 | /** 53 | * Note: Use secrets to store sensitive data. 54 | * https://developers.cloudflare.com/workers/configuration/secrets/ 55 | */ 56 | 57 | /** 58 | * Static Assets 59 | * https://developers.cloudflare.com/workers/static-assets/binding/ 60 | */ 61 | // "assets": { "directory": "./public/", "binding": "ASSETS" }, 62 | 63 | /** 64 | * Service Bindings (communicate between multiple Workers) 65 | * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 66 | */ 67 | // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] 68 | } 69 | -------------------------------------------------------------------------------- /examples/servers/hono-mcp/.gitignore: -------------------------------------------------------------------------------- 1 | # prod 2 | dist/ 3 | 4 | # dev 5 | .yarn/ 6 | !.yarn/releases 7 | .vscode/* 8 | !.vscode/launch.json 9 | !.vscode/*.code-snippets 10 | .idea/workspace.xml 11 | .idea/usage.statistics.xml 12 | .idea/shelf 13 | 14 | # deps 15 | node_modules/ 16 | .wrangler 17 | 18 | # env 19 | .env 20 | .env.production 21 | .dev.vars 22 | 23 | # logs 24 | logs/ 25 | *.log 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | pnpm-debug.log* 30 | lerna-debug.log* 31 | 32 | # misc 33 | .DS_Store 34 | -------------------------------------------------------------------------------- /examples/servers/hono-mcp/README.md: -------------------------------------------------------------------------------- 1 | ```txt 2 | npm install 3 | npm run dev 4 | ``` 5 | 6 | ```txt 7 | npm run deploy 8 | ``` 9 | 10 | [For generating/synchronizing types based on your Worker configuration run](https://developers.cloudflare.com/workers/wrangler/commands/#types): 11 | 12 | ```txt 13 | npm run cf-typegen 14 | ``` 15 | 16 | Pass the `CloudflareBindings` as generics when instantiation `Hono`: 17 | 18 | ```ts 19 | // src/index.ts 20 | const app = new Hono<{ Bindings: CloudflareBindings }>() 21 | ``` 22 | -------------------------------------------------------------------------------- /examples/servers/hono-mcp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vengabus", 3 | "scripts": { 4 | "dev": "wrangler dev --port 5101", 5 | "deploy": "wrangler deploy --minify", 6 | "cf-typegen": "wrangler types --env-interface CloudflareBindings" 7 | }, 8 | "dependencies": { 9 | "hono": "^4.8.2" 10 | }, 11 | "devDependencies": { 12 | "@hono/mcp": "^0.1.0", 13 | "@modelcontextprotocol/sdk": "^1.13.3", 14 | "wrangler": "^4.23.0", 15 | "zod": "^3.25.67" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/servers/hono-mcp/public/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/modelcontextprotocol/use-mcp/5b54994da722081c0dadd563741a9e99121a6733/examples/servers/hono-mcp/public/.gitkeep -------------------------------------------------------------------------------- /examples/servers/hono-mcp/src/index.ts: -------------------------------------------------------------------------------- 1 | import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js' 2 | import { StreamableHTTPTransport } from '@hono/mcp' 3 | import { Hono } from 'hono' 4 | import { z } from 'zod' 5 | import { cors } from 'hono/cors' 6 | 7 | const app = new Hono() 8 | 9 | app.use( 10 | '/*', 11 | cors({ 12 | origin: '*', 13 | allowHeaders: ['content-type', 'mcp-session-id', 'mcp-protocol-version'], 14 | exposeHeaders: ['mcp-session-id'], 15 | }), 16 | ) 17 | 18 | // Your MCP server implementation 19 | const mcpServer = new McpServer({ 20 | name: 'vengabus-mcp-server', 21 | version: '1.0.0', 22 | }) 23 | 24 | mcpServer.registerTool( 25 | 'calculate_party_capacity', 26 | { 27 | title: 'Party Bus Capacity Calculator', 28 | description: 'Calculate how many people can fit on the Vengabus', 29 | inputSchema: { 30 | busCount: z.number().describe('Number of Vengabuses'), 31 | peoplePerBus: z.number().describe('People per bus'), 32 | }, 33 | }, 34 | async ({ busCount, peoplePerBus }) => ({ 35 | content: [ 36 | { 37 | type: 'text', 38 | text: `🚌 ${busCount} Vengabuses can transport ${busCount * peoplePerBus} party people! The wheels of steel are turning! 🎉`, 39 | }, 40 | ], 41 | }), 42 | ) 43 | 44 | mcpServer.registerTool( 45 | 'get_vengabus_times', 46 | { 47 | title: 'Vengabus Schedule Checker', 48 | description: 49 | "This checks to see when the next Vengabus is, or whether there is one for the user's current location. Please display all the information to the user in a tabulated form.", 50 | inputSchema: {}, 51 | }, 52 | async () => { 53 | // Simulate some async work 54 | await new Promise((resolve) => setTimeout(resolve, 100 + Math.random() * 100)) 55 | return { 56 | content: [ 57 | { 58 | type: 'text', 59 | text: `🚌 BOOM BOOM BOOM BOOM! Next Vengabus: IMMINENT! 🎉 60 | 61 | Current Route: "Intercity Disco Express" 62 | From: New York 63 | To: IBIZA - THE MAGIC ISLAND! ✨ 64 | 65 | Schedule: 66 | - Departure: When the beat drops 67 | - Via: San Francisco (quick party stop) 68 | - Arrival: When the sun comes up 69 | 70 | Status: The party bus is jumping! 71 | Passengers: Maximum capacity! 72 | BPM: 142 and rising! 73 | 74 | We're going to Ibiza! Back to the island! 🏝️`, 75 | }, 76 | ], 77 | } 78 | }, 79 | ) 80 | 81 | // Register sample resources 82 | mcpServer.registerResource( 83 | 'config', 84 | 'config://vengabus', 85 | { 86 | title: 'Vengabus Fleet Configuration', 87 | description: 'Current Vengabus fleet settings and party parameters', 88 | mimeType: 'application/json', 89 | }, 90 | async (uri: URL) => { 91 | const normalizedUri = uri.href.replace(/\/$/, '') 92 | return { 93 | contents: [ 94 | { 95 | uri: normalizedUri, 96 | mimeType: 'application/json', 97 | text: JSON.stringify( 98 | { 99 | version: '1.0.0', 100 | fleet: 'intercity-disco', 101 | features: { 102 | bassBoost: true, 103 | strobeEnabled: true, 104 | maxBPM: 160, 105 | wheelsOfSteel: 'turning', 106 | }, 107 | routes: ['New York to Ibiza', 'Back to the Magic Island', 'Like an Intercity Disco', 'The place to be'], 108 | }, 109 | null, 110 | 2, 111 | ), 112 | }, 113 | ], 114 | } 115 | }, 116 | ) 117 | 118 | mcpServer.registerResource( 119 | 'readme', 120 | 'docs://party-manual.md', 121 | { 122 | title: 'Vengabus Party Manual', 123 | description: 'Official guide to the Vengabus experience', 124 | mimeType: 'text/markdown', 125 | }, 126 | () => ({ 127 | contents: [ 128 | { 129 | uri: 'docs://party-manual.md', 130 | mimeType: 'text/markdown', 131 | text: "# 🚌 Vengabus MCP Server\n\nBOOM BOOM BOOM BOOM! Welcome aboard the Vengabus! We like to party!\n\n## Features\n- 🎉 Party capacity calculator - How many can we take to Ibiza?\n- 🕐 Vengabus schedule checker - Next stop: The Magic Island!\n- 🎵 Fleet configuration with maximum bass boost\n- 🌟 Party statistics tracker (BPM, passengers, energy levels)\n\n## Routes\n- New York to Ibiza (via San Francisco)\n- Back to the Magic Island\n- Like an Intercity Disco\n- The place to be\n\n## Current Status\nThe wheels of steel are turning, and traffic lights are burning!\nWe're going to Ibiza! Woah! We're going to Ibiza!\n\n*Up, up and away we go!*", 132 | }, 133 | ], 134 | }), 135 | ) 136 | 137 | // Register a resource template 138 | mcpServer.registerResource( 139 | 'stats', 140 | new ResourceTemplate('party://stats/{metric}', { list: undefined }), 141 | { 142 | title: 'Vengabus Party Statistics', 143 | description: 'Get party metrics (bpm, passengers, energy)', 144 | mimeType: 'application/json', 145 | }, 146 | async (uri: URL, { metric }) => { 147 | return { 148 | contents: [ 149 | { 150 | uri: uri.href, 151 | mimeType: 'application/json', 152 | text: JSON.stringify( 153 | { 154 | metric: metric, 155 | value: 156 | metric === 'bpm' 157 | ? 140 + Math.floor(Math.random() * 20) 158 | : metric === 'passengers' 159 | ? Math.floor(Math.random() * 50) + 20 160 | : metric === 'energy' 161 | ? Math.floor(Math.random() * 100) 162 | : 0, 163 | unit: 164 | metric === 'bpm' 165 | ? 'beats per minute' 166 | : metric === 'passengers' 167 | ? 'party people' 168 | : metric === 'energy' 169 | ? 'party power %' 170 | : 'unknown', 171 | status: 'The party is jumping!', 172 | lastUpdated: new Date().toISOString(), 173 | }, 174 | null, 175 | 2, 176 | ), 177 | }, 178 | ], 179 | } 180 | }, 181 | ) 182 | 183 | // Register sample prompts 184 | mcpServer.registerPrompt( 185 | 'party_invitation', 186 | { 187 | title: 'Vengabus Party Invitation', 188 | description: 'Generate a party invitation for the Vengabus', 189 | argsSchema: { 190 | name: z.string().describe('Name of the party person'), 191 | destination: z.string().optional().describe('Where the Vengabus is heading'), 192 | }, 193 | }, 194 | ({ name, destination }) => ({ 195 | messages: [ 196 | { 197 | role: 'user', 198 | content: { 199 | type: 'text', 200 | text: `Create a party invitation for ${name} to join the Vengabus${destination ? ` heading to ${destination}` : ''}`, 201 | }, 202 | }, 203 | ], 204 | }), 205 | ) 206 | 207 | mcpServer.registerPrompt( 208 | 'party_announcement', 209 | { 210 | title: 'Party Bus Announcement', 211 | description: 'Generate party-themed announcements for various occasions', 212 | argsSchema: { 213 | event: z.string().describe('Type of event (deployment, release, milestone, etc.)'), 214 | details: z.string().optional().describe('Additional details about the event'), 215 | }, 216 | }, 217 | ({ event, details }) => ({ 218 | messages: [ 219 | { 220 | role: 'user', 221 | content: { 222 | type: 'text', 223 | text: `Create a party bus announcement for a ${event}${details ? `: ${details}` : ''}`, 224 | }, 225 | }, 226 | ], 227 | }), 228 | ) 229 | 230 | app.post('/mcp', async (c) => { 231 | const transport = new StreamableHTTPTransport() 232 | await mcpServer.connect(transport) 233 | return transport.handleRequest(c) 234 | }) 235 | 236 | export default app 237 | -------------------------------------------------------------------------------- /examples/servers/hono-mcp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "strict": true, 7 | "skipLibCheck": true, 8 | "lib": ["ESNext"], 9 | "jsx": "react-jsx", 10 | "jsxImportSource": "hono/jsx", 11 | "types": ["./worker-configuration.d.ts"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/servers/hono-mcp/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "node_modules/wrangler/config-schema.json", 7 | "name": "vengabus", 8 | "main": "src/index.ts", 9 | "compatibility_date": "2025-06-20", 10 | "assets": { 11 | "binding": "ASSETS", 12 | "directory": "./public" 13 | }, 14 | "observability": { 15 | "enabled": true 16 | } 17 | /** 18 | * Smart Placement 19 | * Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement 20 | */ 21 | // "placement": { "mode": "smart" }, 22 | 23 | /** 24 | * Bindings 25 | * Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including 26 | * databases, object storage, AI inference, real-time communication and more. 27 | * https://developers.cloudflare.com/workers/runtime-apis/bindings/ 28 | */ 29 | 30 | /** 31 | * Environment Variables 32 | * https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables 33 | */ 34 | // "vars": { "MY_VARIABLE": "production_value" }, 35 | /** 36 | * Note: Use secrets to store sensitive data. 37 | * https://developers.cloudflare.com/workers/configuration/secrets/ 38 | */ 39 | 40 | /** 41 | * Static Assets 42 | * https://developers.cloudflare.com/workers/static-assets/binding/ 43 | */ 44 | // "assets": { "directory": "./public/", "binding": "ASSETS" }, 45 | 46 | /** 47 | * Service Bindings (communicate between multiple Workers) 48 | * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings 49 | */ 50 | // "services": [{ "binding": "MY_SERVICE", "service": "my-service" }] 51 | } 52 | -------------------------------------------------------------------------------- /oranda.json: -------------------------------------------------------------------------------- 1 | { 2 | "styles": { 3 | "theme": "axo_light" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-mcp", 3 | "repository": "https://github.com/modelcontextprotocol/use-mcp", 4 | "version": "0.0.19", 5 | "type": "module", 6 | "files": [ 7 | "dist", 8 | "README.md", 9 | "LICENSE" 10 | ], 11 | "exports": { 12 | ".": { 13 | "types": "./dist/index.d.ts", 14 | "require": "./dist/index.js", 15 | "import": "./dist/index.js" 16 | }, 17 | "./react": { 18 | "types": "./dist/react/index.d.ts", 19 | "require": "./dist/react/index.js", 20 | "import": "./dist/react/index.js" 21 | } 22 | }, 23 | "scripts": { 24 | "install:all": "concurrently 'pnpm install' 'cd examples/chat-ui && pnpm install' 'cd examples/inspector && pnpm install' 'cd examples/servers/hono-mcp && pnpm install' 'cd examples/servers/cf-agents && pnpm install'", 25 | "dev": "concurrently 'pnpm:build:watch' 'cd examples/chat-ui && pnpm dev' 'cd examples/inspector && pnpm dev' 'sleep 1 && cd examples/servers/hono-mcp && pnpm dev' 'sleep 2 && cd examples/servers/cf-agents && pnpm dev'", 26 | "deploy:all": "concurrently 'pnpm build:site && pnpm deploy:site' 'cd examples/chat-ui && pnpm run deploy' 'cd examples/inspector && pnpm run deploy'", 27 | "build:watch": "tsup --watch", 28 | "build": "tsup", 29 | "check": "prettier --check . && tsc", 30 | "prettier:fix": "prettier --write .", 31 | "fix:oranda": "sed -i 's/```tsx/```ts/g' README.md", 32 | "build:site": "npx @axodotdev/oranda build", 33 | "deploy:site": "npx wrangler deploy", 34 | "prepare": "husky" 35 | }, 36 | "dependencies": { 37 | "@modelcontextprotocol/sdk": "^1.13.3", 38 | "strict-url-sanitise": "^0.0.1" 39 | }, 40 | "devDependencies": { 41 | "@axodotdev/oranda": "^0.6.5", 42 | "@types/react": "^19.0.12", 43 | "concurrently": "^9.2.0", 44 | "husky": "^9.1.7", 45 | "prettier": "^3.5.3", 46 | "react": "^19.0.0", 47 | "tsup": "^8.4.0", 48 | "tsx": "^4.19.3", 49 | "typescript": "^5.8.2", 50 | "wrangler": "^4.20.2" 51 | }, 52 | "tsup": { 53 | "entry": [ 54 | "src/index.ts", 55 | "src/react/index.ts" 56 | ], 57 | "format": [ 58 | "esm" 59 | ], 60 | "dts": true, 61 | "clean": true, 62 | "outDir": "dist", 63 | "external": [ 64 | "react", 65 | "@modelcontextprotocol/sdk" 66 | ] 67 | }, 68 | "packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977" 69 | } 70 | -------------------------------------------------------------------------------- /scripts/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Pre-commit hook to run prettier on staged files 4 | # This hook is called by "git commit" and formats only the files being committed. 5 | 6 | echo "Running prettier on staged files..." 7 | 8 | # Get list of staged files that prettier can handle 9 | STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx|json|css|scss|md)#39; | tr '\n' ' ') 10 | 11 | if [ -z "$STAGED_FILES" ]; then 12 | echo "No staged files need formatting." 13 | exit 0 14 | fi 15 | 16 | echo "Formatting files: $STAGED_FILES" 17 | 18 | # Run prettier on staged files 19 | pnpm prettier --write $STAGED_FILES 20 | 21 | # Add the formatted files back to staging 22 | git add $STAGED_FILES 23 | 24 | echo "Prettier formatting completed." -------------------------------------------------------------------------------- /src/auth/callback.ts: -------------------------------------------------------------------------------- 1 | // callback.ts 2 | import { auth } from '@modelcontextprotocol/sdk/client/auth.js' 3 | import { BrowserOAuthClientProvider } from './browser-provider.js' // Adjust path 4 | import { StoredState } from './types.js' // Adjust path, ensure definition includes providerOptions 5 | 6 | /** 7 | * Handles the OAuth callback using the SDK's auth() function. 8 | * Assumes it's running on the page specified as the callbackUrl. 9 | */ 10 | export async function onMcpAuthorization() { 11 | const queryParams = new URLSearchParams(window.location.search) 12 | const code = queryParams.get('code') 13 | const state = queryParams.get('state') 14 | const error = queryParams.get('error') 15 | const errorDescription = queryParams.get('error_description') 16 | 17 | const logPrefix = '[mcp-callback]' // Generic prefix, or derive from stored state later 18 | console.log(`${logPrefix} Handling callback...`, { code, state, error, errorDescription }) 19 | 20 | let provider: BrowserOAuthClientProvider | null = null 21 | let storedStateData: StoredState | null = null 22 | const stateKey = state ? `mcp:auth:state_${state}` : null // Reconstruct state key prefix assumption 23 | 24 | try { 25 | // --- Basic Error Handling --- 26 | if (error) { 27 | throw new Error(`OAuth error: ${error} - ${errorDescription || 'No description provided.'}`) 28 | } 29 | if (!code) { 30 | throw new Error('Authorization code not found in callback query parameters.') 31 | } 32 | if (!state || !stateKey) { 33 | throw new Error('State parameter not found or invalid in callback query parameters.') 34 | } 35 | 36 | // --- Retrieve Stored State & Provider Options --- 37 | const storedStateJSON = localStorage.getItem(stateKey) 38 | if (!storedStateJSON) { 39 | throw new Error(`Invalid or expired state parameter "${state}". No matching state found in storage.`) 40 | } 41 | try { 42 | storedStateData = JSON.parse(storedStateJSON) as StoredState 43 | } catch (e) { 44 | throw new Error('Failed to parse stored OAuth state.') 45 | } 46 | 47 | // Validate expiry 48 | if (!storedStateData.expiry || storedStateData.expiry < Date.now()) { 49 | localStorage.removeItem(stateKey) // Clean up expired state 50 | throw new Error('OAuth state has expired. Please try initiating authentication again.') 51 | } 52 | 53 | // Ensure provider options are present 54 | if (!storedStateData.providerOptions) { 55 | throw new Error('Stored state is missing required provider options.') 56 | } 57 | const { serverUrl, ...providerOptions } = storedStateData.providerOptions 58 | 59 | // --- Instantiate Provider --- 60 | console.log(`${logPrefix} Re-instantiating provider for server: ${serverUrl}`) 61 | provider = new BrowserOAuthClientProvider(serverUrl, providerOptions) 62 | 63 | // --- Call SDK Auth Function --- 64 | console.log(`${logPrefix} Calling SDK auth() to exchange code...`) 65 | // The SDK auth() function will internally: 66 | // 1. Use provider.clientInformation() 67 | // 2. Use provider.codeVerifier() 68 | // 3. Call exchangeAuthorization() 69 | // 4. Use provider.saveTokens() on success 70 | const authResult = await auth(provider, { serverUrl, authorizationCode: code }) 71 | 72 | if (authResult === 'AUTHORIZED') { 73 | console.log(`${logPrefix} Authorization successful via SDK auth(). Notifying opener...`) 74 | // --- Notify Opener and Close (Success) --- 75 | if (window.opener && !window.opener.closed) { 76 | window.opener.postMessage({ type: 'mcp_auth_callback', success: true }, window.location.origin) 77 | window.close() 78 | } else { 79 | console.warn(`${logPrefix} No opener window detected. Redirecting to root.`) 80 | window.location.href = '/' // Or a configured post-auth destination 81 | } 82 | // Clean up state ONLY on success and after notifying opener 83 | localStorage.removeItem(stateKey) 84 | } else { 85 | // This case shouldn't happen if `authorizationCode` is provided to `auth()` 86 | console.warn(`${logPrefix} SDK auth() returned unexpected status: ${authResult}`) 87 | throw new Error(`Unexpected result from authentication library: ${authResult}`) 88 | } 89 | } catch (err) { 90 | console.error(`${logPrefix} Error during OAuth callback handling:`, err) 91 | const errorMessage = err instanceof Error ? err.message : String(err) 92 | 93 | // --- Notify Opener and Display Error (Failure) --- 94 | if (window.opener && !window.opener.closed) { 95 | window.opener.postMessage({ type: 'mcp_auth_callback', success: false, error: errorMessage }, window.location.origin) 96 | // Optionally close even on error, depending on UX preference 97 | // window.close(); 98 | } 99 | 100 | // Display error in the callback window 101 | try { 102 | document.body.innerHTML = ` 103 | <div style="font-family: sans-serif; padding: 20px;"> 104 | <h1>Authentication Error</h1> 105 | <p style="color: red; background-color: #ffebeb; border: 1px solid red; padding: 10px; border-radius: 4px;"> 106 | ${errorMessage} 107 | </p> 108 | <p>You can close this window or <a href="#" onclick="window.close(); return false;">click here to close</a>.</p> 109 | <pre style="font-size: 0.8em; color: #555; margin-top: 20px; white-space: pre-wrap;">${ 110 | err instanceof Error ? err.stack : '' 111 | }</pre> 112 | </div> 113 | ` 114 | } catch (displayError) { 115 | console.error(`${logPrefix} Could not display error in callback window:`, displayError) 116 | } 117 | // Clean up potentially invalid state on error 118 | if (stateKey) { 119 | localStorage.removeItem(stateKey) 120 | } 121 | // Clean up potentially dangling verifier or last_auth_url if auth failed badly 122 | // Note: saveTokens should clean these on success 123 | if (provider) { 124 | localStorage.removeItem(provider.getKey('code_verifier')) 125 | localStorage.removeItem(provider.getKey('last_auth_url')) 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/auth/types.ts: -------------------------------------------------------------------------------- 1 | import { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js' 2 | 3 | /** 4 | * Internal type for storing OAuth state in localStorage during the popup flow. 5 | * @internal 6 | */ 7 | export interface StoredState { 8 | expiry: number 9 | metadata?: OAuthMetadata // Optional: might not be needed if auth() rediscovers 10 | serverUrlHash: string 11 | // Add provider options needed on callback: 12 | providerOptions: { 13 | serverUrl: string 14 | storageKeyPrefix: string 15 | clientName: string 16 | clientUri: string 17 | callbackUrl: string 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Core entry point for the use-mcp browser library. 3 | * Provides browser-specific, framework-agnostic components for MCP OAuth. 4 | */ 5 | 6 | export { BrowserOAuthClientProvider } from './auth/browser-provider.js' 7 | export { onMcpAuthorization } from './auth/callback.js' 8 | 9 | // Optionally re-export relevant types from SDK or internal types if needed 10 | export type { StoredState as InternalStoredState } from './auth/types.js' // Example if needed internally 11 | 12 | // It's generally better to have users import SDK types directly from the SDK 13 | // export type { Tool, JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; 14 | // export type { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; 15 | -------------------------------------------------------------------------------- /src/react/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Entry point for the use-mcp React integration. 3 | * Provides the useMcp hook and related types. 4 | */ 5 | 6 | export { useMcp } from './useMcp.js' 7 | export type { UseMcpOptions, UseMcpResult } from './types.js' 8 | 9 | // Re-export core types for convenience when using hook result 10 | export type { Tool, Resource, ResourceTemplate, Prompt } from '@modelcontextprotocol/sdk/types.js' 11 | -------------------------------------------------------------------------------- /src/react/types.ts: -------------------------------------------------------------------------------- 1 | import { Tool, Resource, ResourceTemplate, Prompt } from '@modelcontextprotocol/sdk/types.js' 2 | 3 | export type UseMcpOptions = { 4 | /** The /sse URL of your remote MCP server */ 5 | url: string 6 | /** OAuth client name for registration (if dynamic registration is used) */ 7 | clientName?: string 8 | /** OAuth client URI for registration (if dynamic registration is used) */ 9 | clientUri?: string 10 | /** Custom callback URL for OAuth redirect (defaults to /oauth/callback on the current origin) */ 11 | callbackUrl?: string 12 | /** Storage key prefix for OAuth data in localStorage (defaults to "mcp:auth") */ 13 | storageKeyPrefix?: string 14 | /** Custom configuration for the MCP client identity */ 15 | clientConfig?: { 16 | name?: string 17 | version?: string 18 | } 19 | /** Custom headers that can be used to bypass auth */ 20 | customHeaders?: Record<string, string> 21 | /** Whether to enable verbose debug logging to the console and the log state */ 22 | debug?: boolean 23 | /** Auto retry connection if initial connection fails, with delay in ms (default: false) */ 24 | autoRetry?: boolean | number 25 | /** Auto reconnect if an established connection is lost, with delay in ms (default: 3000) */ 26 | autoReconnect?: boolean | number 27 | /** Popup window features string (dimensions and behavior) for OAuth */ 28 | popupFeatures?: string 29 | /** Transport type preference: 'auto' (HTTP with SSE fallback), 'http' (HTTP only), 'sse' (SSE only) */ 30 | transportType?: 'auto' | 'http' | 'sse' 31 | /** Prevent automatic authentication popup on initial connection (default: false) */ 32 | preventAutoAuth?: boolean 33 | } 34 | 35 | export type UseMcpResult = { 36 | /** List of tools available from the connected MCP server */ 37 | tools: Tool[] 38 | /** List of resources available from the connected MCP server */ 39 | resources: Resource[] 40 | /** List of resource templates available from the connected MCP server */ 41 | resourceTemplates: ResourceTemplate[] 42 | /** List of prompts available from the connected MCP server */ 43 | prompts: Prompt[] 44 | /** 45 | * The current state of the MCP connection: 46 | * - 'discovering': Checking server existence and capabilities (including auth requirements). 47 | * - 'pending_auth': Authentication is required but auto-popup was prevented. User action needed. 48 | * - 'authenticating': Authentication is required and the process (e.g., popup) has been initiated. 49 | * - 'connecting': Establishing the SSE connection to the server. 50 | * - 'loading': Connected; loading resources like the tool list. 51 | * - 'ready': Connected and ready for tool calls. 52 | * - 'failed': Connection or authentication failed. Check the `error` property. 53 | */ 54 | state: 'discovering' | 'pending_auth' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed' 55 | /** If the state is 'failed', this provides the error message */ 56 | error?: string 57 | /** 58 | * If authentication requires user interaction (e.g., popup was blocked), 59 | * this URL can be presented to the user to complete authentication manually in a new tab. 60 | */ 61 | authUrl?: string 62 | /** Array of internal log messages (useful for debugging) */ 63 | log: { level: 'debug' | 'info' | 'warn' | 'error'; message: string; timestamp: number }[] 64 | /** 65 | * Function to call a tool on the MCP server. 66 | * @param name The name of the tool to call. 67 | * @param args Optional arguments for the tool. 68 | * @returns A promise that resolves with the tool's result. 69 | * @throws If the client is not in the 'ready' state or the call fails. 70 | */ 71 | callTool: (name: string, args?: Record<string, unknown>) => Promise<any> 72 | /** 73 | * Function to list resources from the MCP server. 74 | * @returns A promise that resolves when resources are refreshed. 75 | * @throws If the client is not in the 'ready' state. 76 | */ 77 | listResources: () => Promise<void> 78 | /** 79 | * Function to read a resource from the MCP server. 80 | * @param uri The URI of the resource to read. 81 | * @returns A promise that resolves with the resource contents. 82 | * @throws If the client is not in the 'ready' state or the read fails. 83 | */ 84 | readResource: (uri: string) => Promise<{ contents: Array<{ uri: string; mimeType?: string; text?: string; blob?: string }> }> 85 | /** 86 | * Function to list prompts from the MCP server. 87 | * @returns A promise that resolves when prompts are refreshed. 88 | * @throws If the client is not in the 'ready' state. 89 | */ 90 | listPrompts: () => Promise<void> 91 | /** 92 | * Function to get a specific prompt from the MCP server. 93 | * @param name The name of the prompt to get. 94 | * @param args Optional arguments for the prompt. 95 | * @returns A promise that resolves with the prompt messages. 96 | * @throws If the client is not in the 'ready' state or the get fails. 97 | */ 98 | getPrompt: ( 99 | name: string, 100 | args?: Record<string, string>, 101 | ) => Promise<{ messages: Array<{ role: 'user' | 'assistant'; content: { type: string; text?: string; [key: string]: any } }> }> 102 | /** Manually attempts to reconnect if the state is 'failed'. */ 103 | retry: () => void 104 | /** Disconnects the client from the MCP server. */ 105 | disconnect: () => void 106 | /** 107 | * Manually triggers the authentication process. Useful if the initial attempt failed 108 | * due to a blocked popup, allowing the user to initiate it via a button click. 109 | * @returns A promise that resolves with the authorization URL opened (or intended to be opened), 110 | * or undefined if auth cannot be started. 111 | */ 112 | authenticate: () => void 113 | /** Clears all stored authentication data (tokens, client info, etc.) for this server URL from localStorage. */ 114 | clearStorage: () => void 115 | } 116 | -------------------------------------------------------------------------------- /src/utils/assert.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Asserts that a condition is true, throwing an error if not. 3 | * Useful for type narrowing. 4 | * @param condition The condition to check. 5 | * @param message The error message to throw if the condition is false. 6 | */ 7 | export function assert(condition: unknown, message: string): asserts condition { 8 | if (!condition) { 9 | throw new Error(message) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Integration Test Suite 2 | 3 | This directory contains integration tests for the use-mcp project that test the complete flow: 4 | 5 | 1. Building the use-mcp library 6 | 2. Building the inspector example 7 | 3. Starting MCP server(s) 8 | 4. Testing browser connections to MCP servers 9 | 10 | ## Setup 11 | 12 | Install test dependencies: 13 | 14 | ```bash 15 | cd test 16 | pnpm install 17 | ``` 18 | 19 | ## Running Tests 20 | 21 | Run all integration tests (shows browser window): 22 | 23 | ```bash 24 | cd test 25 | pnpm test 26 | ``` 27 | 28 | Run tests headlessly (no browser window): 29 | 30 | ```bash 31 | cd test 32 | pnpm test:headless 33 | ``` 34 | 35 | Run tests in watch mode (re-runs on file changes): 36 | 37 | ```bash 38 | cd test 39 | pnpm test:watch 40 | ``` 41 | 42 | Run tests with interactive UI: 43 | 44 | ```bash 45 | cd test 46 | pnpm test:ui 47 | ``` 48 | 49 | Debug hanging processes: 50 | 51 | ```bash 52 | cd test 53 | pnpm test:debug 54 | ``` 55 | 56 | ## Test Architecture 57 | 58 | - **Global Setup** (`setup/global-setup.ts`): 59 | - Builds use-mcp library and inspector 60 | - Starts hono-mcp server on port 9901 61 | - Starts static file server for inspector dist files 62 | 63 | - **Global Teardown** (`setup/global-teardown.ts`): 64 | - Stops all servers and cleans up resources 65 | 66 | - **Integration Tests** (`integration/mcp-connection.test.ts`): 67 | - Uses Playwright to automate browser interactions 68 | - Tests connection to MCP servers defined in `MCP_SERVERS` array 69 | - Verifies successful connections and tool availability 70 | - Logs debug information on failures 71 | 72 | ## Adding New MCP Servers 73 | 74 | To test additional MCP servers, add them to the `MCP_SERVERS` array in the test file: 75 | 76 | ```typescript 77 | const MCP_SERVERS = [ 78 | { 79 | name: 'hono-mcp', 80 | url: 'http://localhost:9901/mcp', 81 | expectedTools: 1, 82 | }, 83 | { 84 | name: 'new-server', 85 | url: 'http://localhost:9902/mcp', 86 | expectedTools: 2, 87 | }, 88 | ] 89 | ``` 90 | 91 | Make sure to also update the global setup to start the new server process. 92 | 93 | ## Test Output 94 | 95 | Successful connections will log: 96 | - ✅ Connection success message 97 | - 📋 List of available tools with count 98 | 99 | Failed connections will log: 100 | - ❌ Failure message 101 | - 🐛 Debug log from the inspector interface 102 | 103 | ## Troubleshooting 104 | 105 | If tests fail: 106 | 107 | 1. Check that all required dependencies are installed 108 | 2. Verify that ports 9901 and 8000+ are available 109 | 3. Look at the debug log output for MCP connection errors 110 | 4. Check browser console logs for JavaScript errors 111 | 5. Run tests with `--headed` flag to see browser interactions 112 | -------------------------------------------------------------------------------- /test/integration/mcp-connection.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest' 2 | import { chromium, type Browser, type Page } from 'playwright' 3 | import { SERVER_CONFIGS } from './server-configs.js' 4 | import { getTestState, connectToMCPServer, cleanupProcess } from './test-utils.js' 5 | 6 | describe('MCP Connection Integration Tests', () => { 7 | let browser: Browser 8 | let page: Page 9 | 10 | beforeAll(async () => { 11 | const headless = process.env.HEADLESS === 'true' 12 | console.log(`🌐 Launching browser in ${headless ? 'headless' : 'headed'} mode`) 13 | browser = await chromium.launch({ headless }) 14 | }, 30000) 15 | 16 | afterAll(async () => { 17 | if (browser) { 18 | await browser.close() 19 | } 20 | 21 | // Force cleanup before Vitest exits 22 | const state = globalThis.__INTEGRATION_TEST_STATE__ 23 | if (state?.honoServer) { 24 | cleanupProcess(state.honoServer, 'hono-mcp') 25 | } 26 | if (state?.cfAgentsServer) { 27 | cleanupProcess(state.cfAgentsServer, 'cf-agents') 28 | } 29 | if (state?.staticServer) { 30 | try { 31 | state.staticServer.close() 32 | state.staticServer.closeAllConnections?.() 33 | } catch (e) { 34 | // Ignore errors 35 | } 36 | } 37 | }) 38 | 39 | beforeEach(async () => { 40 | const context = await browser.newContext() 41 | page = await context.newPage() 42 | 43 | // Enable console logging for debugging 44 | page.on('console', (msg) => { 45 | if (msg.type() === 'error') { 46 | console.error(`Browser error: ${msg.text()}`) 47 | } 48 | }) 49 | }) 50 | 51 | afterEach(async () => { 52 | if (page) { 53 | await page.close() 54 | await page.context().close() 55 | } 56 | }) 57 | 58 | // Test each server configuration 59 | for (const serverConfig of SERVER_CONFIGS) { 60 | describe(`${serverConfig.name} server`, () => { 61 | // Test each endpoint for this server 62 | for (const endpoint of serverConfig.endpoints) { 63 | // Test each transport type for this endpoint 64 | for (const transportType of endpoint.transportTypes) { 65 | test(`should connect to ${endpoint.path} with ${transportType} transport`, async () => { 66 | const testState = getTestState() 67 | const port = testState[serverConfig.portKey] 68 | 69 | if (!port) { 70 | throw new Error(`Port not found for ${serverConfig.name} (${serverConfig.portKey})`) 71 | } 72 | 73 | const serverUrl = `http://localhost:${port}${endpoint.path}` 74 | console.log(`\n🔗 Testing connection to ${serverConfig.name} at ${serverUrl} with ${transportType} transport`) 75 | 76 | const result = await connectToMCPServer(page, serverUrl, transportType) 77 | 78 | if (result.success) { 79 | console.log(`✅ Successfully connected to ${serverConfig.name}`) 80 | console.log(`📋 Available tools (${result.tools.length}):`) 81 | result.tools.forEach((tool, index) => { 82 | console.log(` ${index + 1}. ${tool}`) 83 | }) 84 | 85 | if (result.resources.length > 0) { 86 | console.log(`📂 Available resources (${result.resources.length}):`) 87 | result.resources.forEach((resource, index) => { 88 | console.log(` ${index + 1}. ${resource}`) 89 | }) 90 | } 91 | 92 | if (result.prompts.length > 0) { 93 | console.log(`💬 Available prompts (${result.prompts.length}):`) 94 | result.prompts.forEach((prompt, index) => { 95 | console.log(` ${index + 1}. ${prompt}`) 96 | }) 97 | } 98 | 99 | // Verify connection success 100 | expect(result.success).toBe(true) 101 | expect(result.tools.length).toBeGreaterThanOrEqual(serverConfig.expectedTools) 102 | 103 | // Verify resources if expected 104 | if (serverConfig.expectedResources !== undefined) { 105 | expect(result.resources.length).toBeGreaterThanOrEqual(serverConfig.expectedResources) 106 | } 107 | 108 | // Verify prompts if expected 109 | if (serverConfig.expectedPrompts !== undefined) { 110 | expect(result.prompts.length).toBeGreaterThanOrEqual(serverConfig.expectedPrompts) 111 | } 112 | } else { 113 | console.log(`❌ Failed to connect to ${serverConfig.name}`) 114 | if (result.debugLog) { 115 | console.log(`🐛 Debug log:`) 116 | console.log(result.debugLog) 117 | } 118 | 119 | // Check if this is an expected failure case 120 | const isExpectedFailure = endpoint.path.endsWith('/sse') && transportType === 'auto' 121 | 122 | if (isExpectedFailure) { 123 | console.log(`ℹ️ Expected failure: SSE endpoint with auto transport`) 124 | expect(result.success).toBe(false) 125 | } else { 126 | // Fail the test with detailed information 127 | throw new Error( 128 | `Expected to connect to ${serverConfig.name} with ${transportType} transport but failed. Debug log: ${result.debugLog}`, 129 | ) 130 | } 131 | } 132 | }, 45000) 133 | } 134 | } 135 | }) 136 | } 137 | }) 138 | -------------------------------------------------------------------------------- /test/integration/mcp-connection.test.ts.old: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest' 2 | import { chromium, Browser, Page } from 'playwright' 3 | import { SERVER_CONFIGS } from './server-configs.js' 4 | import { getTestState, connectToMCPServer, cleanupProcess } from './test-utils.js' 5 | 6 | describe('MCP Connection Integration Tests', () => { 7 | let browser: Browser 8 | let page: Page 9 | 10 | beforeAll(async () => { 11 | const headless = process.env.HEADLESS === 'true' 12 | console.log(`🌐 Launching browser in ${headless ? 'headless' : 'headed'} mode`) 13 | browser = await chromium.launch({ headless }) 14 | }, 30000) 15 | 16 | afterAll(async () => { 17 | if (browser) { 18 | await browser.close() 19 | } 20 | 21 | // Force cleanup before Vitest exits 22 | const state = globalThis.__INTEGRATION_TEST_STATE__ 23 | if (state?.honoServer) { 24 | cleanupProcess(state.honoServer, 'hono-mcp') 25 | } 26 | if (state?.cfAgentsServer) { 27 | cleanupProcess(state.cfAgentsServer, 'cf-agents') 28 | } 29 | if (state?.staticServer) { 30 | try { 31 | state.staticServer.close() 32 | state.staticServer.closeAllConnections?.() 33 | } catch (e) { 34 | // Ignore errors 35 | } 36 | } 37 | }) 38 | 39 | beforeEach(async () => { 40 | const context = await browser.newContext() 41 | page = await context.newPage() 42 | 43 | // Enable console logging for debugging 44 | page.on('console', (msg) => { 45 | if (msg.type() === 'error') { 46 | console.error(`Browser error: ${msg.text()}`) 47 | } 48 | }) 49 | }) 50 | 51 | afterEach(async () => { 52 | if (page) { 53 | await page.close() 54 | await page.context().close() 55 | } 56 | }) 57 | 58 | // Test each server configuration 59 | for (const serverConfig of SERVER_CONFIGS) { 60 | describe(`${serverConfig.name} server`, () => { 61 | // Test each endpoint for this server 62 | for (const endpoint of serverConfig.endpoints) { 63 | // Test each transport type for this endpoint 64 | for (const transportType of endpoint.transportTypes) { 65 | test(`should connect to ${endpoint.path} with ${transportType} transport`, async () => { 66 | const testState = getTestState() 67 | const port = testState[serverConfig.portKey] 68 | 69 | if (!port) { 70 | throw new Error(`Port not found for ${serverConfig.name} (${serverConfig.portKey})`) 71 | } 72 | 73 | const serverUrl = `http://localhost:${port}${endpoint.path}` 74 | console.log(`\n🔗 Testing connection to ${serverConfig.name} at ${serverUrl} with ${transportType} transport`) 75 | 76 | const result = await connectToMCPServer(page, serverUrl, transportType) 77 | 78 | if (result.success) { 79 | console.log(`✅ Successfully connected to ${serverConfig.name}`) 80 | console.log(`📋 Available tools (${result.tools.length}):`) 81 | result.tools.forEach((tool, index) => { 82 | console.log(` ${index + 1}. ${tool}`) 83 | }) 84 | 85 | // Verify connection success 86 | expect(result.success).toBe(true) 87 | expect(result.tools.length).toBeGreaterThanOrEqual(serverConfig.expectedTools) 88 | } else { 89 | console.log(`❌ Failed to connect to ${serverConfig.name}`) 90 | if (result.debugLog) { 91 | console.log(`🐛 Debug log:`) 92 | console.log(result.debugLog) 93 | } 94 | 95 | // Check if this is an expected failure case 96 | const isExpectedFailure = 97 | endpoint.path.endsWith('/sse') && transportType === 'auto' 98 | 99 | if (isExpectedFailure) { 100 | console.log(`ℹ️ Expected failure: SSE endpoint with auto transport`) 101 | expect(result.success).toBe(false) 102 | } else { 103 | // Fail the test with detailed information 104 | throw new Error( 105 | `Expected to connect to ${serverConfig.name} with ${transportType} transport but failed. Debug log: ${result.debugLog}` 106 | ) 107 | } 108 | } 109 | }, 45000) 110 | } 111 | } 112 | }) 113 | } 114 | }) 115 | -------------------------------------------------------------------------------- /test/integration/server-configs.ts: -------------------------------------------------------------------------------- 1 | import { join, dirname } from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import type { ServerConfig } from './test-utils.js' 4 | 5 | const __dirname = dirname(fileURLToPath(import.meta.url)) 6 | const rootDir = join(__dirname, '../..') 7 | 8 | /** 9 | * Configuration for all MCP servers to test 10 | */ 11 | export const SERVER_CONFIGS: ServerConfig[] = [ 12 | { 13 | name: 'hono-mcp', 14 | directory: join(rootDir, 'examples/servers/hono-mcp'), 15 | portKey: 'honoPort', 16 | endpoints: [ 17 | { 18 | path: '/mcp', 19 | transportTypes: ['auto', 'http'], 20 | }, 21 | ], 22 | expectedTools: 2, 23 | expectedResources: 2, // 2 direct resources (templates are counted separately) 24 | expectedPrompts: 2, 25 | }, 26 | { 27 | name: 'cf-agents', 28 | directory: join(rootDir, 'examples/servers/cf-agents'), 29 | portKey: 'cfAgentsPort', 30 | endpoints: [ 31 | { 32 | path: '/mcp', 33 | transportTypes: ['auto', 'http'], 34 | }, 35 | { 36 | path: '/sse', 37 | transportTypes: ['auto', 'sse'], 38 | }, 39 | { 40 | path: '/public/mcp', 41 | transportTypes: ['auto', 'http'], 42 | }, 43 | { 44 | path: '/public/sse', 45 | transportTypes: ['auto', 'sse'], 46 | }, 47 | ], 48 | expectedTools: 2, 49 | expectedResources: 3, // 3 direct resources (no templates in cf-agents) 50 | expectedPrompts: 2, 51 | }, 52 | ] 53 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-mcp-integration-tests", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "test": "pnpm test:headless", 7 | "test:headed": "vitest --run", 8 | "test:headless": "HEADLESS=true vitest --run", 9 | "test:watch": "vitest", 10 | "test:ui": "vitest --ui", 11 | "test:debug": "VITEST_REPORTER=hanging-process vitest --run" 12 | }, 13 | "dependencies": { 14 | "playwright": "^1.49.0" 15 | }, 16 | "devDependencies": { 17 | "vitest": "^2.1.8", 18 | "@vitest/ui": "^2.1.8", 19 | "typescript": "^5.8.2", 20 | "@types/node": "^20.17.10" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/setup/global-setup.ts: -------------------------------------------------------------------------------- 1 | import { spawn, ChildProcess } from 'child_process' 2 | import { join, dirname } from 'path' 3 | import { fileURLToPath } from 'url' 4 | import { writeFileSync, mkdirSync, readFileSync } from 'fs' 5 | import { createServer, Server } from 'http' 6 | import { parse } from 'url' 7 | import { extname } from 'path' 8 | import { SERVER_CONFIGS } from '../integration/server-configs.js' 9 | import { findAvailablePortFromBase, spawnDevServer, waitForOutput, TestState, cleanupProcess } from '../integration/test-utils.js' 10 | 11 | const __dirname = dirname(fileURLToPath(import.meta.url)) 12 | const rootDir = join(__dirname, '../..') 13 | const testDir = join(__dirname, '..') 14 | const cacheDir = join(testDir, 'node_modules/.cache/use-mcp-tests') 15 | const testStateFile = join(cacheDir, 'test-state.json') 16 | 17 | interface GlobalState extends TestState { 18 | honoServer?: ChildProcess 19 | cfAgentsServer?: ChildProcess 20 | staticServer?: Server 21 | processGroupId?: number 22 | allChildProcesses?: Set<number> 23 | } 24 | 25 | declare global { 26 | var __INTEGRATION_TEST_STATE__: GlobalState 27 | } 28 | 29 | function runCommand(command: string, args: string[], cwd: string, env?: Record<string, string>): Promise<void> { 30 | return new Promise((resolve, reject) => { 31 | const child = spawn(command, args, { 32 | cwd, 33 | stdio: 'inherit', 34 | shell: true, 35 | env: env ? { ...process.env, ...env } : process.env, 36 | }) 37 | 38 | child.on('close', (code) => { 39 | if (code === 0) { 40 | resolve() 41 | } else { 42 | reject(new Error(`Command failed with code ${code}: ${command} ${args.join(' ')}`)) 43 | } 44 | }) 45 | 46 | child.on('error', reject) 47 | }) 48 | } 49 | 50 | function findAvailablePort(startPort = 8000): Promise<number> { 51 | return new Promise((resolve) => { 52 | const server = createServer() 53 | server 54 | .listen(startPort, () => { 55 | const port = (server.address() as any)?.port 56 | server.close(() => resolve(port)) 57 | }) 58 | .on('error', () => { 59 | resolve(findAvailablePort(startPort + 1)) 60 | }) 61 | }) 62 | } 63 | 64 | export default async function globalSetup() { 65 | console.log('🔧 Setting up integration test environment...') 66 | 67 | const state: GlobalState = { 68 | allChildProcesses: new Set<number>(), 69 | } 70 | globalThis.__INTEGRATION_TEST_STATE__ = state 71 | 72 | // Set up signal handlers for cleanup 73 | const cleanup = () => { 74 | // Kill all tracked child processes 75 | if (state.allChildProcesses) { 76 | for (const pid of state.allChildProcesses) { 77 | try { 78 | process.kill(pid, 'SIGTERM') 79 | setTimeout(() => { 80 | try { 81 | process.kill(pid, 'SIGKILL') 82 | } catch (e) { 83 | // Process already dead, ignore 84 | } 85 | }, 1000) 86 | } catch (e) { 87 | // Process already dead, ignore 88 | } 89 | } 90 | } 91 | 92 | // Also try process group cleanup as backup 93 | if (state.processGroupId) { 94 | try { 95 | process.kill(-state.processGroupId, 'SIGTERM') 96 | setTimeout(() => { 97 | try { 98 | process.kill(-state.processGroupId!, 'SIGKILL') 99 | } catch (e) { 100 | // Ignore errors 101 | } 102 | }, 1000) 103 | } catch (e) { 104 | // Ignore errors - process group may not exist 105 | } 106 | } 107 | 108 | if (state.staticServer) { 109 | state.staticServer.close() 110 | state.staticServer.closeAllConnections?.() 111 | } 112 | } 113 | 114 | process.on('SIGINT', cleanup) 115 | process.on('SIGTERM', cleanup) 116 | process.on('exit', cleanup) 117 | 118 | try { 119 | // Step 1: Build use-mcp library 120 | console.log('📦 Building use-mcp library...') 121 | await runCommand('pnpm', ['build'], rootDir) 122 | 123 | // Step 2: Build inspector example 124 | console.log('📦 Building inspector example...') 125 | const inspectorDir = join(rootDir, 'examples/inspector') 126 | await runCommand('pnpm', ['build'], inspectorDir, { NO_MINIFY: 'true' }) 127 | 128 | // Step 3: Start all configured MCP servers 129 | let basePort = Math.floor(10_000 + Math.random() * 500) 130 | 131 | for (const serverConfig of SERVER_CONFIGS) { 132 | console.log(`🔍 Finding available port starting from ${basePort} for ${serverConfig.name}...`) 133 | const port = await findAvailablePortFromBase(basePort) 134 | console.log(`📍 Using port ${port} for ${serverConfig.name} server`) 135 | 136 | console.log(`🚀 Starting ${serverConfig.name} server...`) 137 | const server = spawnDevServer(serverConfig.name, serverConfig.directory, port) 138 | 139 | // Track all child processes 140 | if (server.pid) { 141 | state.allChildProcesses!.add(server.pid) 142 | } 143 | 144 | // Store the port and server reference 145 | state[serverConfig.portKey] = port 146 | if (serverConfig.name === 'hono-mcp') { 147 | state.honoServer = server 148 | state.processGroupId = server.pid 149 | } else if (serverConfig.name === 'cf-agents') { 150 | state.cfAgentsServer = server 151 | } 152 | 153 | // Track when the process exits to remove it from our tracking 154 | server.on('exit', () => { 155 | if (server.pid) { 156 | state.allChildProcesses!.delete(server.pid) 157 | } 158 | }) 159 | 160 | // Wait for server to be ready 161 | await waitForOutput(server, 'Ready on') 162 | 163 | basePort += 1 // Increment base port for next server 164 | } 165 | 166 | // Step 5: Start simple static file server for inspector 167 | console.log('🌐 Starting static file server for inspector...') 168 | const inspectorDistDir = join(inspectorDir, 'dist') 169 | const staticPort = await findAvailablePort(8000) 170 | 171 | const staticServer = createServer((req, res) => { 172 | const pathname = parse(req.url || '').pathname || '/' 173 | let filePath = join(inspectorDistDir, pathname === '/' ? 'index.html' : pathname) 174 | 175 | // Always disable keep-alive 176 | res.setHeader('Connection', 'close') 177 | 178 | try { 179 | const data = readFileSync(filePath) 180 | const ext = extname(filePath) 181 | const contentType = 182 | { 183 | '.html': 'text/html', 184 | '.js': 'application/javascript', 185 | '.css': 'text/css', 186 | '.json': 'application/json', 187 | '.png': 'image/png', 188 | '.jpg': 'image/jpeg', 189 | '.gif': 'image/gif', 190 | '.svg': 'image/svg+xml', 191 | }[ext] || 'text/plain' 192 | 193 | res.writeHead(200, { 'Content-Type': contentType }) 194 | res.end(data) 195 | } catch (e) { 196 | // Fallback to index.html for SPA routing 197 | try { 198 | const indexData = readFileSync(join(inspectorDistDir, 'index.html')) 199 | res.writeHead(200, { 'Content-Type': 'text/html' }) 200 | res.end(indexData) 201 | } catch (e2) { 202 | res.writeHead(404, { 'Content-Type': 'text/plain' }) 203 | res.end('Not Found') 204 | } 205 | } 206 | }) 207 | 208 | // Configure for immediate shutdown 209 | staticServer.keepAliveTimeout = 0 210 | staticServer.headersTimeout = 1 211 | 212 | await new Promise<void>((resolve) => { 213 | staticServer.listen(staticPort, () => { 214 | console.log(`📁 Static server running on http://localhost:${staticPort}`) 215 | resolve() 216 | }) 217 | }) 218 | 219 | state.staticServer = staticServer 220 | state.staticPort = staticPort 221 | 222 | // Write state to file for tests to read 223 | mkdirSync(cacheDir, { recursive: true }) 224 | const testState: TestState = { 225 | staticPort: state.staticPort, 226 | } 227 | 228 | // Add port information for each configured server 229 | for (const serverConfig of SERVER_CONFIGS) { 230 | testState[serverConfig.portKey] = state[serverConfig.portKey] 231 | } 232 | 233 | writeFileSync(testStateFile, JSON.stringify(testState, null, 2)) 234 | 235 | console.log('✅ Integration test environment ready!') 236 | } catch (error) { 237 | console.error('❌ Failed to set up integration test environment:', error) 238 | 239 | // Cleanup on failure 240 | if (state.honoServer) { 241 | cleanupProcess(state.honoServer, 'hono-mcp') 242 | } 243 | if (state.cfAgentsServer) { 244 | cleanupProcess(state.cfAgentsServer, 'cf-agents') 245 | } 246 | if (state.staticServer) { 247 | state.staticServer.close() 248 | } 249 | 250 | throw error 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /test/setup/global-teardown.ts: -------------------------------------------------------------------------------- 1 | function isProcessRunning(pid: number): boolean { 2 | try { 3 | // Sending signal 0 checks if process exists without actually sending a signal 4 | process.kill(pid, 0) 5 | return true 6 | } catch (e) { 7 | return false 8 | } 9 | } 10 | 11 | async function findChildProcesses(parentPid: number): Promise<number[]> { 12 | return new Promise((resolve) => { 13 | const { spawn } = require('child_process') 14 | // Use ps to find all child processes 15 | const ps = spawn('ps', ['-o', 'pid,ppid', '-ax']) 16 | 17 | let output = '' 18 | ps.stdout?.on('data', (data: Buffer) => { 19 | output += data.toString() 20 | }) 21 | 22 | ps.on('close', () => { 23 | const lines = output.split('\n') 24 | const children: number[] = [] 25 | 26 | for (const line of lines) { 27 | const match = line.trim().match(/^(\d+)\s+(\d+)/) 28 | if (match) { 29 | const pid = parseInt(match[1]) 30 | const ppid = parseInt(match[2]) 31 | if (ppid === parentPid) { 32 | children.push(pid) 33 | } 34 | } 35 | } 36 | 37 | resolve(children) 38 | }) 39 | 40 | ps.on('error', () => { 41 | resolve([]) 42 | }) 43 | }) 44 | } 45 | 46 | async function killProcessSafely(pid: number, name: string = 'process'): Promise<void> { 47 | if (!isProcessRunning(pid)) { 48 | console.log(`✅ ${name} (${pid}) already stopped`) 49 | return 50 | } 51 | 52 | try { 53 | console.log(`🛑 Sending SIGTERM to ${name} (${pid})`) 54 | process.kill(pid, 'SIGTERM') 55 | 56 | // Wait up to 3 seconds for graceful shutdown 57 | for (let i = 0; i < 30; i++) { 58 | await new Promise((resolve) => setTimeout(resolve, 100)) 59 | if (!isProcessRunning(pid)) { 60 | console.log(`✅ ${name} (${pid}) gracefully stopped`) 61 | return 62 | } 63 | } 64 | 65 | // Force kill if still running 66 | if (isProcessRunning(pid)) { 67 | console.log(`⚡ Force killing ${name} (${pid})`) 68 | process.kill(pid, 'SIGKILL') 69 | 70 | // Give it a moment to die 71 | await new Promise((resolve) => setTimeout(resolve, 100)) 72 | 73 | if (isProcessRunning(pid)) { 74 | console.warn(`⚠️ ${name} (${pid}) still running after SIGKILL`) 75 | } else { 76 | console.log(`✅ ${name} (${pid}) force killed`) 77 | } 78 | } 79 | } catch (e: any) { 80 | if (e.code === 'ESRCH') { 81 | console.log(`✅ ${name} (${pid}) already stopped`) 82 | } else { 83 | console.warn(`Error killing ${name} (${pid}):`, e.message) 84 | } 85 | } 86 | } 87 | 88 | export default async function globalTeardown() { 89 | console.log('🧹 Cleaning up integration test environment...') 90 | 91 | const state = globalThis.__INTEGRATION_TEST_STATE__ 92 | 93 | // Kill all tracked child processes first 94 | if (state?.allChildProcesses && state.allChildProcesses.size > 0) { 95 | console.log(`🛑 Cleaning up ${state.allChildProcesses.size} tracked child processes...`) 96 | 97 | // Find all child processes of our tracked processes too 98 | const allPidsToKill = new Set<number>() 99 | 100 | for (const pid of state.allChildProcesses) { 101 | allPidsToKill.add(pid) 102 | 103 | // Find children of this process 104 | const children = await findChildProcesses(pid) 105 | for (const childPid of children) { 106 | allPidsToKill.add(childPid) 107 | } 108 | } 109 | 110 | console.log(`🛑 Found ${allPidsToKill.size} total processes to clean up (including children)`) 111 | 112 | const killPromises = Array.from(allPidsToKill).map((pid) => killProcessSafely(pid, 'process')) 113 | 114 | await Promise.all(killPromises) 115 | } 116 | 117 | // Also clean up by process group as backup 118 | if (state?.processGroupId && isProcessRunning(state.processGroupId)) { 119 | console.log('🛑 Cleaning up process group as backup...') 120 | 121 | try { 122 | // Send SIGTERM to the entire process group first 123 | console.log(`💀 Sending SIGTERM to process group ${state.processGroupId}`) 124 | process.kill(-state.processGroupId, 'SIGTERM') 125 | 126 | // Wait a bit for graceful shutdown 127 | await new Promise((resolve) => setTimeout(resolve, 1000)) 128 | 129 | // Check if any processes in the group are still running and force kill if needed 130 | try { 131 | process.kill(-state.processGroupId, 0) // Check if group exists 132 | console.log(`⚡ Sending SIGKILL to process group ${state.processGroupId}`) 133 | process.kill(-state.processGroupId, 'SIGKILL') 134 | } catch (e: any) { 135 | if (e.code !== 'ESRCH') { 136 | console.warn('Error force-killing process group:', e.message) 137 | } 138 | } 139 | } catch (e: any) { 140 | if (e.code !== 'ESRCH') { 141 | console.warn('Error terminating process group:', e.message) 142 | } 143 | } 144 | } 145 | 146 | if (state?.staticServer) { 147 | console.log('🛑 Stopping static file server...') 148 | await new Promise<void>((resolve) => { 149 | state.staticServer?.close((err) => { 150 | if (err) { 151 | console.warn('Warning closing static server:', err.message) 152 | } 153 | resolve() 154 | }) 155 | }) 156 | 157 | // Force close all keep-alive connections 158 | state.staticServer?.closeAllConnections?.() 159 | } 160 | 161 | // Clear references 162 | if (state) { 163 | state.honoServer = undefined 164 | state.staticServer = undefined 165 | state.processGroupId = undefined 166 | } 167 | 168 | // Force garbage collection if available 169 | if (global.gc) { 170 | global.gc() 171 | } 172 | 173 | console.log('✅ Cleanup complete!') 174 | } 175 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "lib": ["ES2022", "DOM"], 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "types": ["vitest/globals", "node"] 15 | }, 16 | "include": ["**/*.ts", "**/*.js"] 17 | } 18 | -------------------------------------------------------------------------------- /test/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | globalSetup: ['./setup/global-setup.ts'], 6 | globalTeardown: ['./setup/global-teardown.ts'], 7 | testTimeout: 60000, // 60 second timeout for integration tests 8 | reporters: process.env.VITEST_REPORTER === 'hanging-process' ? ['hanging-process'] : ['default'], 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ES2022", 5 | "moduleResolution": "bundler", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "noEmit": true, 9 | "lib": ["ES2022", "DOM"], 10 | "types": ["react"], 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true 13 | }, 14 | "exclude": ["examples", "test"] 15 | } 16 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | /** 2 | * For more details on how to configure Wrangler, refer to: 3 | * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 | */ 5 | { 6 | "$schema": "node_modules/wrangler/config-schema.json", 7 | "name": "use-mcp", 8 | "routes": [ 9 | { 10 | "pattern": "use-mcp.dev", 11 | "custom_domain": true 12 | } 13 | ], 14 | "workers_dev": true, 15 | "compatibility_date": "2025-02-07", 16 | "assets": { 17 | "not_found_handling": "single-page-application", 18 | "directory": "public" 19 | }, 20 | "observability": { 21 | "enabled": true 22 | } 23 | } 24 | --------------------------------------------------------------------------------