Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40c02d2d98 | ||
|
|
8337ff0a68 |
||
|
|
8952e3b47d | ||
|
|
043efbcf12 |
||
|
|
c47ea828c3 |
||
|
|
c3ee0e9b09 |
||
|
|
5b54994da7 | ||
|
|
c260e971d9 |
||
|
|
85efc2de59 | ||
|
|
448e633eef | ||
|
|
c7e3e2303d |
||
|
|
156c956112 | ||
|
|
42621bfe02 | ||
|
|
977fa6f8b4 | ||
|
|
3af42b0e77 | ||
|
|
94ba6ff372 |
||
|
|
e682ad8396 | ||
|
|
6a42f9dc93 | ||
|
|
e0743c35e4 | ||
|
|
b909730ae2 | ||
|
|
d4220ec5f2 | ||
|
|
7a923c3509 | ||
|
|
8377abf268 | ||
|
|
8373adfdf9 | ||
|
|
25797dd4bb | ||
|
|
f50a1c588f | ||
|
|
ec0eae5ea3 | ||
|
|
ac52a72557 | ||
|
|
26965cb6a3 |
||
|
|
c189d5af65 | ||
|
|
eb9432aed2 | ||
|
|
feeaade511 | ||
|
|
f770d57e0a |
||
|
|
d80a7e68b0 |
||
|
|
0ea0bc48c6 |
||
|
|
fe65d50ec3 |
||
|
|
7f019fed7f |
||
|
|
ace3220fca |
||
|
|
6a3130a075 | ||
|
|
effb28af19 | ||
|
|
8276b337fe | ||
|
|
85e21784fd | ||
|
|
31d3108225 | ||
|
|
381001eb74 | ||
|
|
fa0180330f | ||
|
|
84b58759ed | ||
|
|
a225929ae2 | ||
|
|
b326e36ccc | ||
|
|
ef08e97a5f | ||
|
|
2b8b3773b5 | ||
|
|
960631bb74 |
124 changed files with 36638 additions and 395 deletions
50
.github/workflows/ci.yml
vendored
Normal file
50
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies - root
|
||||
run: pnpm install
|
||||
|
||||
- name: Install dependencies - inspector example
|
||||
run: pnpm install
|
||||
working-directory: examples/inspector
|
||||
|
||||
- name: Install dependencies - cf-agents example
|
||||
run: pnpm install
|
||||
working-directory: examples/servers/cf-agents
|
||||
|
||||
- name: Install dependencies - hono-mcp example
|
||||
run: pnpm install
|
||||
working-directory: examples/servers/hono-mcp
|
||||
|
||||
- name: Install dependencies - test
|
||||
run: pnpm install && pnpm exec playwright install
|
||||
working-directory: test
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm test:headless
|
||||
working-directory: test
|
||||
13
.github/workflows/publish.yml
vendored
13
.github/workflows/publish.yml
vendored
|
|
@ -1,12 +1,11 @@
|
|||
name: Publish Any Commit
|
||||
on:
|
||||
# Temporarily disabled - uncomment to re-enable
|
||||
# pull_request:
|
||||
# push:
|
||||
# branches:
|
||||
# - '**'
|
||||
# tags:
|
||||
# - '!**'
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- '!**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,4 +5,4 @@ dist
|
|||
.aider*
|
||||
|
||||
**/.claude/settings.local.json
|
||||
public
|
||||
/public
|
||||
|
|
|
|||
1
.husky/pre-commit
Executable file
1
.husky/pre-commit
Executable file
|
|
@ -0,0 +1 @@
|
|||
./scripts/pre-commit
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
pnpm-lock.yaml
|
||||
*.md
|
||||
examples
|
||||
.wrangler
|
||||
.vscode
|
||||
test-results
|
||||
|
|
|
|||
17
AGENT.md
17
AGENT.md
|
|
@ -1,10 +1,21 @@
|
|||
# use-mcp Project Guidelines
|
||||
|
||||
## Build, Lint, and Test Commands
|
||||
- `pnpm dev`: Run development build with watch mode
|
||||
- `pnpm dev`: Run development build with watch mode and start all examples/servers
|
||||
- Chat UI: http://localhost:5002
|
||||
- Inspector: http://localhost:5001
|
||||
- Hono MCP Server: http://localhost:5101
|
||||
- CF Agents MCP Server: http://localhost:5102
|
||||
- `pnpm build`: Build the project
|
||||
- `pnpm check`: Run prettier checks and TypeScript type checking
|
||||
|
||||
### Integration Tests (in /test directory)
|
||||
- `cd test && pnpm test`: Run integration tests headlessly (default)
|
||||
- `cd test && pnpm test:headed`: Run integration tests with visible browser
|
||||
- `cd test && pnpm test:headless`: Run integration tests headlessly
|
||||
- `cd test && pnpm test:watch`: Run integration tests in watch mode
|
||||
- `cd test && pnpm test:ui`: Run integration tests with interactive UI
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Imports
|
||||
|
|
@ -27,6 +38,10 @@
|
|||
- Log errors with appropriate levels (debug, info, warn, error)
|
||||
- Defensive error handling with specific error types when available
|
||||
|
||||
## Development Workflow
|
||||
- **Commit changes frequently** after each logical change or debugging step
|
||||
- Use descriptive commit messages that explain what was changed and why
|
||||
|
||||
### React Patterns
|
||||
- Use React hooks with useRef for mutable values
|
||||
- Stable callbacks with useCallback and appropriate dependencies
|
||||
74
README.md
74
README.md
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
A lightweight React hook for connecting to [Model Context Protocol (MCP)](https://github.com/modelcontextprotocol) servers. Simplifies authentication and tool calling for AI systems implementing the MCP standard.
|
||||
|
||||
Try it out: [MCP Inspector](https://inspector.use-mcp.dev) | [Cloudflare Workers AI Playground](https://playground.ai.cloudflare.com/)
|
||||
Try it out: [Chat Demo](https://chat.use-mcp.dev) | [MCP Inspector](https://inspector.use-mcp.dev) | [Cloudflare Workers AI Playground](https://playground.ai.cloudflare.com/)
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -20,11 +20,39 @@ pnpm add use-mcp
|
|||
yarn add use-mcp
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
To run the development environment with all examples and servers:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This starts:
|
||||
- **Inspector**: http://localhost:5001 - MCP server debugging tool
|
||||
- **Chat UI**: http://localhost:5002 - Example chat interface
|
||||
- **Hono MCP Server**: http://localhost:5101 - Example MCP server
|
||||
- **CF Agents MCP Server**: http://localhost:5102 - Cloudflare Workers AI MCP server
|
||||
|
||||
### Testing
|
||||
|
||||
Integration tests are located in the `test/` directory and run headlessly by default:
|
||||
|
||||
```bash
|
||||
cd test && pnpm test # Run tests headlessly (default)
|
||||
cd test && pnpm test:headed # Run tests with visible browser
|
||||
cd test && pnpm test:watch # Run tests in watch mode
|
||||
cd test && pnpm test:ui # Run tests with interactive UI
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- 🔄 Automatic connection management with reconnection and retries
|
||||
- 🔐 OAuth authentication flow handling with popup and fallback support
|
||||
- 📦 Simple React hook interface for MCP integration
|
||||
- 🧰 Full support for MCP tools, resources, and prompts
|
||||
- 📄 Access server resources and read their contents
|
||||
- 💬 Use server-provided prompt templates
|
||||
- 🧰 TypeScript types for editor assistance and type checking
|
||||
- 📝 Comprehensive logging for debugging
|
||||
- 🌐 Works with both HTTP and SSE (Server-Sent Events) transports
|
||||
|
|
@ -36,10 +64,14 @@ import { useMcp } from 'use-mcp/react'
|
|||
|
||||
function MyAIComponent() {
|
||||
const {
|
||||
state, // Connection state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed'
|
||||
state, // Connection state: 'discovering' | 'pending_auth' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed'
|
||||
tools, // Available tools from MCP server
|
||||
resources, // Available resources from MCP server
|
||||
prompts, // Available prompts from MCP server
|
||||
error, // Error message if connection failed
|
||||
callTool, // Function to call tools on the MCP server
|
||||
readResource, // Function to read resource contents
|
||||
getPrompt, // Function to get prompt messages
|
||||
retry, // Retry connection manually
|
||||
authenticate, // Manually trigger authentication
|
||||
clearStorage, // Clear stored tokens and credentials
|
||||
|
|
@ -83,6 +115,32 @@ function MyAIComponent() {
|
|||
))}
|
||||
</ul>
|
||||
<button onClick={handleSearch}>Search</button>
|
||||
|
||||
{/* Example: Display and read resources */}
|
||||
{resources.length > 0 && (
|
||||
<div>
|
||||
<h3>Resources: {resources.length}</h3>
|
||||
<button onClick={async () => {
|
||||
const content = await readResource(resources[0].uri)
|
||||
console.log('Resource content:', content)
|
||||
}}>
|
||||
Read First Resource
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Example: Use prompts */}
|
||||
{prompts.length > 0 && (
|
||||
<div>
|
||||
<h3>Prompts: {prompts.length}</h3>
|
||||
<button onClick={async () => {
|
||||
const result = await getPrompt(prompts[0].name)
|
||||
console.log('Prompt messages:', result.messages)
|
||||
}}>
|
||||
Get First Prompt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -167,17 +225,27 @@ function useMcp(options: UseMcpOptions): UseMcpResult
|
|||
| `debug` | `boolean` | Whether to enable verbose debug logging |
|
||||
| `autoRetry` | `boolean \| number` | Auto retry connection if initial connection fails, with delay in ms |
|
||||
| `autoReconnect` | `boolean \| number` | Auto reconnect if an established connection is lost, with delay in ms (default: 3000) |
|
||||
| `transportType` | `'auto' \| 'http' \| 'sse'` | Transport type preference: 'auto' (HTTP with SSE fallback), 'http' (HTTP only), 'sse' (SSE only) (default: 'auto') |
|
||||
| `preventAutoAuth` | `boolean` | Prevent automatic authentication popup on initial connection (default: false) |
|
||||
| `onPopupWindow` | `(url: string, features: string, window: Window \| null) => void` | Callback invoked just after the authentication popup window is opened |
|
||||
|
||||
#### Return Value
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `state` | `string` | Current connection state: 'discovering', 'authenticating', 'connecting', 'loading', 'ready', 'failed' |
|
||||
| `state` | `string` | Current connection state: 'discovering', 'pending_auth', 'authenticating', 'connecting', 'loading', 'ready', 'failed' |
|
||||
| `tools` | `Tool[]` | Available tools from the MCP server |
|
||||
| `resources` | `Resource[]` | Available resources from the MCP server |
|
||||
| `resourceTemplates` | `ResourceTemplate[]` | Available resource templates from the MCP server |
|
||||
| `prompts` | `Prompt[]` | Available prompts from the MCP server |
|
||||
| `error` | `string \| undefined` | Error message if connection failed |
|
||||
| `authUrl` | `string \| undefined` | Manual authentication URL if popup is blocked |
|
||||
| `log` | `{ level: 'debug' \| 'info' \| 'warn' \| 'error'; message: string; timestamp: number }[]` | Array of log messages |
|
||||
| `callTool` | `(name: string, args?: Record<string, unknown>) => Promise<any>` | Function to call a tool on the MCP server |
|
||||
| `listResources` | `() => Promise<void>` | Refresh the list of available resources |
|
||||
| `readResource` | `(uri: string) => Promise<{ contents: Array<...> }>` | Read the contents of a specific resource |
|
||||
| `listPrompts` | `() => Promise<void>` | Refresh the list of available prompts |
|
||||
| `getPrompt` | `(name: string, args?: Record<string, string>) => Promise<{ messages: Array<...> }>` | Get a specific prompt with optional arguments |
|
||||
| `retry` | `() => void` | Manually attempt to reconnect |
|
||||
| `disconnect` | `() => void` | Disconnect from the MCP server |
|
||||
| `authenticate` | `() => void` | Manually trigger authentication |
|
||||
|
|
|
|||
3
examples/chat-ui/.env.example
Normal file
3
examples/chat-ui/.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# OpenRouter OAuth Configuration
|
||||
# Note: OpenRouter OAuth doesn't require client ID, only callback URL configuration
|
||||
VITE_OPENROUTER_REDIRECT_URI=http://localhost:5002/oauth/openrouter/callback
|
||||
30
examples/chat-ui/.gitignore
vendored
Normal file
30
examples/chat-ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.wrangler
|
||||
|
||||
# Playwright test results
|
||||
test-results/
|
||||
playwright-report/
|
||||
7
examples/chat-ui/.prettierrc
Normal file
7
examples/chat-ui/.prettierrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 140
|
||||
}
|
||||
73
examples/chat-ui/AGENT.md
Normal file
73
examples/chat-ui/AGENT.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# AI Chat Template - Development Guide
|
||||
|
||||
## Commands
|
||||
- **Dev server**: `pnpm dev`
|
||||
- **Build**: `pnpm build` (runs TypeScript compilation then Vite build)
|
||||
- **Lint**: `pnpm lint` (ESLint)
|
||||
- **Deploy**: `pnpm deploy` (builds and deploys with Wrangler)
|
||||
- **Test**: `pnpm test` (Playwright E2E tests)
|
||||
- **Test UI**: `pnpm test:ui` (Playwright test runner with UI)
|
||||
- **Test headed**: `pnpm test:headed` (Run tests in visible browser)
|
||||
|
||||
## Code Style
|
||||
- **Formatting**: Prettier with 2-space tabs, single quotes, no semicolons, 140 char line width
|
||||
- **Imports**: Use `.tsx`/`.ts` extensions in imports, group by external/internal
|
||||
- **Components**: React FC with explicit typing, PascalCase names
|
||||
- **Hooks**: Custom hooks start with `use`, camelCase
|
||||
- **Types**: Define interfaces in `src/types/index.ts`, use `type` for unions
|
||||
- **Files**: Use PascalCase for components, camelCase for hooks/utilities
|
||||
- **State**: Use proper TypeScript typing for all state variables
|
||||
- **Error handling**: Use try/catch blocks with proper error propagation
|
||||
- **Database**: IndexedDB with typed interfaces, async/await pattern
|
||||
- **Styling**: Tailwind CSS classes, responsive design patterns
|
||||
|
||||
## Tech Stack
|
||||
React 19, TypeScript, Vite, Tailwind CSS, Hono API, Cloudflare Workers, IndexedDB, React Router, use-mcp
|
||||
|
||||
## Chat UI Specific Guidelines
|
||||
|
||||
### Background Animation
|
||||
- Use CSS `hue-rotate` filter for color cycling (more performant than gradient animation)
|
||||
- Isolate animations to `::before` pseudo-elements with `z-index: -1` to prevent inheritance
|
||||
- Use `background-size: 100% 100vh` and `background-repeat: repeat-y` for repeating patterns
|
||||
- Initialize with random animation delay using CSS custom properties
|
||||
|
||||
### Modal Patterns
|
||||
- Use `rgba(0,0,0,0.8)` for modal backdrops (avoid gradients that cause banding)
|
||||
- Implement click-outside-to-dismiss with `onClick` on backdrop and `stopPropagation` on content
|
||||
- Add `cursor-pointer` to all clickable elements including close buttons
|
||||
- Use `document.body.style.overflow = 'hidden'` to prevent background scrolling
|
||||
|
||||
### MCP Server Management
|
||||
- Store multiple servers in localStorage as JSON array (`mcpServers`)
|
||||
- Track tool counts separately (`mcpServerToolCounts`) for disabled servers
|
||||
- Use server-specific IDs for state management
|
||||
- Each MCP server gets unique `useMcp` hook instance for proper isolation
|
||||
- OAuth state parameter automatically handles multiple server differentiation
|
||||
|
||||
### State Persistence
|
||||
- Use localStorage for user preferences (model selection, server configurations)
|
||||
- Use sessionStorage for temporary state (single server URL in legacy components)
|
||||
- Clear related data when removing servers or models
|
||||
|
||||
### UI Indicators
|
||||
- Use emoji-based indicators (🧠 for models, 🔌 for MCP servers)
|
||||
- Format counts as `enabled/total` (e.g., "1/2 servers, 3/5 tools")
|
||||
- Place model selector on left, MCP servers on right for balance
|
||||
- Show "none" instead of red symbols for cleaner unconfigured states
|
||||
|
||||
### Component Organization
|
||||
- Keep disabled components in React tree but hidden with CSS (`{false && ...}`)
|
||||
- This prevents MCP connections from being destroyed when modals close
|
||||
- Use conditional rendering sparingly, prefer CSS visibility for stateful components
|
||||
|
||||
### Routing and OAuth
|
||||
- Use React Router for OAuth callback routes (`/oauth/callback`)
|
||||
- OAuth callback should match main app styling with loading indicators
|
||||
- use-mcp library handles multiple server OAuth flows automatically via state parameters
|
||||
|
||||
### Performance Considerations
|
||||
- Avoid animating gradients directly (causes repainting)
|
||||
- Use transform and filter animations (hardware accelerated)
|
||||
- Aggregate tools from multiple sources efficiently
|
||||
- Minimize localStorage reads in render loops
|
||||
92
examples/chat-ui/MODEL_SELECTOR_GUIDE.md
Normal file
92
examples/chat-ui/MODEL_SELECTOR_GUIDE.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Model Selector Guide
|
||||
|
||||
This guide explains how to use the new favorites-based model selector system.
|
||||
|
||||
## Overview
|
||||
|
||||
The chat UI now supports:
|
||||
- **39 models** from 3 providers (Anthropic, Groq, OpenRouter)
|
||||
- **Favorites system** - star your preferred models
|
||||
- **Search functionality** - find models by name or provider
|
||||
- **Tool filtering** - show only models that support tool calling
|
||||
- **OAuth authentication** - seamless OpenRouter integration
|
||||
|
||||
## Features
|
||||
|
||||
### Model Selection
|
||||
- Click the model selector to view all available models
|
||||
- Models are loaded from the live models.dev API
|
||||
- Each model shows provider logo, name, and capabilities
|
||||
|
||||
### Favorites System
|
||||
- ⭐ Star models to add them to your favorites
|
||||
- Starred models are saved to local storage
|
||||
- Use the "Favorites" filter to show only starred models
|
||||
|
||||
### Search & Filtering
|
||||
- 🔍 Search box to find models by name or provider
|
||||
- 🔧 "Tools Only" filter (appears when MCP tools are available)
|
||||
- ⭐ "Favorites" filter to show only starred models
|
||||
|
||||
### Authentication
|
||||
- **API Keys**: Enter manually for Anthropic and Groq
|
||||
- **OAuth**: One-click authentication for OpenRouter
|
||||
- Authentication status shown with icons (✓ or ⚠️)
|
||||
|
||||
## Provider Support
|
||||
|
||||
### Anthropic
|
||||
- **Models**: 9 models including Claude 4 Sonnet
|
||||
- **Auth**: API key (requires manual entry)
|
||||
- **Tools**: All models support tool calling
|
||||
|
||||
### Groq
|
||||
- **Models**: 13 models including Llama and Qwen variants
|
||||
- **Auth**: API key (requires manual entry)
|
||||
- **Tools**: 11 models support tool calling
|
||||
|
||||
### OpenRouter
|
||||
- **Models**: 17 models from various providers
|
||||
- **Auth**: OAuth PKCE flow (one-click authentication)
|
||||
- **Tools**: All models support tool calling
|
||||
|
||||
## Setting Up OpenRouter OAuth
|
||||
|
||||
1. Create an OpenRouter account at https://openrouter.ai
|
||||
2. Go to your dashboard and create an OAuth app
|
||||
3. Set the redirect URI to: `http://localhost:5002/oauth/openrouter/callback`
|
||||
4. Copy your client ID to your `.env` file:
|
||||
```
|
||||
VITE_OPENROUTER_CLIENT_ID=your_client_id_here
|
||||
```
|
||||
|
||||
## Usage Tips
|
||||
|
||||
1. **Star your favorites** - This makes model selection much faster
|
||||
2. **Use search** - With 39 models, search helps find what you need
|
||||
3. **Filter by tools** - When using MCP tools, enable "Tools Only" filter
|
||||
4. **Try different providers** - Each has unique models with different strengths
|
||||
|
||||
## Model Data Updates
|
||||
|
||||
Model data is fetched from models.dev API and can be updated with:
|
||||
|
||||
```bash
|
||||
pnpm update-models
|
||||
```
|
||||
|
||||
This will refresh the model list with the latest information from the API.
|
||||
|
||||
## Local Storage
|
||||
|
||||
The following preferences are saved locally:
|
||||
- **Favorites**: `aiChatTemplate_favorites_v1`
|
||||
- **Tokens**: `aiChatTemplate_token_[provider]`
|
||||
- **Selected Model**: `aiChatTemplate_selectedModel`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **OAuth popup blocked**: Allow popups for this site
|
||||
- **Authentication failed**: Check your API keys or re-authenticate
|
||||
- **Model not working**: Verify the model supports the features you're using
|
||||
- **Tools not showing**: Enable "Tools Only" filter when MCP tools are configured
|
||||
36
examples/chat-ui/README.md
Normal file
36
examples/chat-ui/README.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# AI Chat with MCP
|
||||
|
||||
A React-based AI chat application demonstrating [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) integration with multiple AI providers.
|
||||
|
||||
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.
|
||||
|
||||
**Live demo**: [chat.use-mcp.dev](https://chat.use-mcp.dev)
|
||||
|
||||
## Features
|
||||
|
||||
- **MCP Integration**: Connect to MCP servers with OAuth authentication support
|
||||
- **Multi-model Support**: Anthropic (Claude) and Groq (Llama) models with API key authentication
|
||||
- **Local Storage**: Conversations stored in browser's IndexedDB
|
||||
- **Static Deployment**: Builds to static assets for deployment anywhere
|
||||
- **Modern Stack**: React 19, TypeScript, Tailwind CSS, Vite
|
||||
|
||||
## Get started
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Build and deploy:
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
pnpm run deploy # deploys to Cloudflare Pages
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
- **Dev server**: `pnpm dev` (runs on port 5002)
|
||||
- **Build**: `pnpm build`
|
||||
- **Lint**: `pnpm lint`
|
||||
- **Test**: `pnpm test` (Playwright E2E tests)
|
||||
118
examples/chat-ui/api/index.ts
Normal file
118
examples/chat-ui/api/index.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import type { LanguageModelV1StreamPart } from 'ai'
|
||||
import { streamText, extractReasoningMiddleware, wrapLanguageModel } from 'ai'
|
||||
import { createWorkersAI } from 'workers-ai-provider'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
interface Env {
|
||||
ASSETS: Fetcher
|
||||
AI: Ai
|
||||
}
|
||||
|
||||
type message = {
|
||||
role: 'system' | 'user' | 'assistant' | 'data'
|
||||
content: string
|
||||
}
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>()
|
||||
|
||||
// Handle the /api/chat endpoint
|
||||
app.post('/api/chat', async (c) => {
|
||||
try {
|
||||
const { messages, reasoning }: { messages: message[]; reasoning: boolean } = await c.req.json()
|
||||
|
||||
const workersai = createWorkersAI({ binding: c.env.AI })
|
||||
|
||||
// Choose model based on reasoning preference
|
||||
const model = reasoning
|
||||
? wrapLanguageModel({
|
||||
model: workersai('@cf/deepseek-ai/deepseek-r1-distill-qwen-32b'),
|
||||
middleware: [
|
||||
extractReasoningMiddleware({ tagName: 'think' }),
|
||||
//custom middleware to inject <think> tag at the beginning of a reasoning if it is missing
|
||||
{
|
||||
wrapGenerate: async ({ doGenerate }) => {
|
||||
const result = await doGenerate()
|
||||
|
||||
if (!result.text?.includes('<think>')) {
|
||||
result.text = `<think>${result.text}`
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
wrapStream: async ({ doStream }) => {
|
||||
const { stream, ...rest } = await doStream()
|
||||
|
||||
let generatedText = ''
|
||||
const transformStream = new TransformStream<LanguageModelV1StreamPart, LanguageModelV1StreamPart>({
|
||||
transform(chunk, controller) {
|
||||
//we are manually adding the <think> tag because some times, distills of reasoning models omit it
|
||||
if (chunk.type === 'text-delta') {
|
||||
if (!generatedText.includes('<think>')) {
|
||||
generatedText += '<think>'
|
||||
controller.enqueue({
|
||||
type: 'text-delta',
|
||||
textDelta: '<think>',
|
||||
})
|
||||
}
|
||||
generatedText += chunk.textDelta
|
||||
}
|
||||
|
||||
controller.enqueue(chunk)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
stream: stream.pipeThrough(transformStream),
|
||||
...rest,
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
: workersai('@cf/meta/llama-3.3-70b-instruct-fp8-fast')
|
||||
|
||||
const systemPrompt: message = {
|
||||
role: 'system',
|
||||
content: `
|
||||
- Do not wrap your responses in html tags.
|
||||
- Do not apply any formatting to your responses.
|
||||
- You are an expert conversational chatbot. Your objective is to be as helpful as possible.
|
||||
- You must keep your responses relevant to the user's prompt.
|
||||
- You must respond with a maximum of 512 tokens (300 words).
|
||||
- You must respond clearly and concisely, and explain your logic if required.
|
||||
- You must not provide any personal information.
|
||||
- Do not respond with your own personal opinions, and avoid topics unrelated to the user's prompt.
|
||||
${
|
||||
messages.length <= 1 &&
|
||||
`- Important REMINDER: You MUST provide a 5 word title at the END of your response using <chat-title> </chat-title> tags.
|
||||
If you do not do this, this session will error.
|
||||
For example, <chat-title>Hello and Welcome</chat-title> Hi, how can I help you today?
|
||||
`
|
||||
}
|
||||
`,
|
||||
}
|
||||
|
||||
const text = await streamText({
|
||||
model,
|
||||
messages: [systemPrompt, ...messages],
|
||||
maxTokens: 2048,
|
||||
maxRetries: 3,
|
||||
})
|
||||
|
||||
return text.toDataStreamResponse({
|
||||
sendReasoning: true,
|
||||
})
|
||||
} catch (error) {
|
||||
return c.json({ error: `Chat completion failed. ${(error as Error)?.message}` }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle static assets and fallback routes
|
||||
app.all('*', async (c) => {
|
||||
if (c.env.ASSETS) {
|
||||
return c.env.ASSETS.fetch(c.req.raw)
|
||||
}
|
||||
return c.text('Not found', 404)
|
||||
})
|
||||
|
||||
export default app
|
||||
45
examples/chat-ui/e2e/build.spec.ts
Normal file
45
examples/chat-ui/e2e/build.spec.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
interface ExecError extends Error {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
}
|
||||
|
||||
test.describe('Build Tests', () => {
|
||||
test('should build without type errors', async () => {
|
||||
try {
|
||||
const { stderr } = await execAsync('pnpm build', {
|
||||
cwd: process.cwd(),
|
||||
timeout: 60000,
|
||||
})
|
||||
|
||||
// Check for TypeScript compilation errors
|
||||
expect(stderr).not.toContain('error TS')
|
||||
expect(stderr).not.toContain('Type error')
|
||||
} catch (error) {
|
||||
const execError = error as ExecError
|
||||
console.error('Build failed:', execError.stdout, execError.stderr)
|
||||
throw new Error(`Build failed: ${execError.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('should lint without errors', async () => {
|
||||
try {
|
||||
const { stderr } = await execAsync('pnpm lint', {
|
||||
cwd: process.cwd(),
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// ESLint should pass without errors
|
||||
expect(stderr).not.toContain('error')
|
||||
} catch (error) {
|
||||
const execError = error as ExecError
|
||||
console.error('Lint failed:', execError.stdout, execError.stderr)
|
||||
throw new Error(`Lint failed: ${execError.message}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
178
examples/chat-ui/e2e/chat.spec.ts
Normal file
178
examples/chat-ui/e2e/chat.spec.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Chat Functionality', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock the external AI API calls
|
||||
await page.route('https://api.groq.com/**', async (route) => {
|
||||
// Mock streaming response for Groq
|
||||
const mockResponse = 'Hello! This is a mocked response from the AI assistant.'
|
||||
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body: mockResponse,
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('https://api.anthropic.com/**', async (route) => {
|
||||
// Mock streaming response for Anthropic
|
||||
const mockResponse = 'Hello! This is a mocked response from Claude.'
|
||||
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
body: mockResponse,
|
||||
})
|
||||
})
|
||||
|
||||
// Mock the local API endpoint
|
||||
await page.route('/api/chat', async (route) => {
|
||||
const mockResponse = {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content: 'Hello! This is a mocked response from the local API.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(mockResponse),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('should load the chat interface', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// Check if the main elements are present
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
|
||||
// Look for chat-related elements (adjust selectors based on your UI)
|
||||
const messageInput = page.locator('textarea, input[type="text"]').first()
|
||||
await expect(messageInput).toBeVisible()
|
||||
|
||||
// Check for a send button or similar
|
||||
const sendButton = page
|
||||
.locator('button')
|
||||
.filter({ hasText: /send|submit/i })
|
||||
.first()
|
||||
if (await sendButton.isVisible()) {
|
||||
await expect(sendButton).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test('should send a message and receive a response', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// Wait for the page to load
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Find the message input field
|
||||
const messageInput = page.locator('textarea, input[type="text"]').first()
|
||||
await expect(messageInput).toBeVisible()
|
||||
|
||||
// Type a test message
|
||||
const testMessage = 'Hello, can you help me?'
|
||||
await messageInput.fill(testMessage)
|
||||
await expect(messageInput).toHaveValue(testMessage)
|
||||
|
||||
// Send the message by pressing Enter (most common method)
|
||||
await messageInput.press('Enter')
|
||||
|
||||
// Wait a moment for the form submission to process
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Check that a conversation has started - look for any text content that might be a message
|
||||
const pageContent = await page.textContent('body')
|
||||
expect(pageContent).toContain(testMessage)
|
||||
|
||||
// The input may or may not clear depending on implementation details
|
||||
// This is less critical than the message appearing
|
||||
})
|
||||
|
||||
test('should handle API errors gracefully', async ({ page }) => {
|
||||
// Override mocks to simulate API errors
|
||||
await page.route('https://api.groq.com/**', async (route) => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ error: 'Internal server error' }),
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('https://api.anthropic.com/**', async (route) => {
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ error: 'Unauthorized' }),
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
// Try to send a message
|
||||
const messageInput = page.locator('textarea, input[type="text"]').first()
|
||||
await messageInput.fill('Test message')
|
||||
|
||||
const sendButton = page
|
||||
.locator('button')
|
||||
.filter({ hasText: /send|submit/i })
|
||||
.first()
|
||||
if (await sendButton.isVisible()) {
|
||||
await sendButton.click()
|
||||
} else {
|
||||
await messageInput.press('Enter')
|
||||
}
|
||||
|
||||
// Should handle the error without crashing
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// The app should still be functional (not showing a white screen or error page)
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should persist conversations', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
// Send a message
|
||||
const messageInput = page.locator('textarea, input[type="text"]').first()
|
||||
await messageInput.fill('Test message for persistence')
|
||||
|
||||
const sendButton = page
|
||||
.locator('button')
|
||||
.filter({ hasText: /send|submit/i })
|
||||
.first()
|
||||
if (await sendButton.isVisible()) {
|
||||
await sendButton.click()
|
||||
} else {
|
||||
await messageInput.press('Enter')
|
||||
}
|
||||
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Refresh the page
|
||||
await page.reload()
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Check if the conversation persisted (this depends on your IndexedDB implementation)
|
||||
// We'll just verify the page loads correctly after refresh
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
|
||||
const inputAfterReload = page.locator('textarea, input[type="text"]').first()
|
||||
await expect(inputAfterReload).toBeVisible()
|
||||
})
|
||||
})
|
||||
25
examples/chat-ui/eslint.config.js
Normal file
25
examples/chat-ui/eslint.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
},
|
||||
}
|
||||
)
|
||||
13
examples/chat-ui/index.html
Normal file
13
examples/chat-ui/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>use-mcp chat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
examples/chat-ui/package.json
Normal file
59
examples/chat-ui/package.json
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"name": "ai-chat-template",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=5002",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"deploy": "npm run build && npm exec wrangler deploy",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:html": "playwright test --reporter=html",
|
||||
"update-models": "tsx scripts/update-models.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^1.2.12",
|
||||
"@ai-sdk/groq": "^1.2.9",
|
||||
"@ai-sdk/openai": "^1.3.23",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.0.14",
|
||||
"ai": "^4.1.61",
|
||||
"hono": "^4.7.9",
|
||||
"idb": "^8.0.2",
|
||||
"lucide-react": "^0.482.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.6.2",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwindcss": "^4.0.14",
|
||||
"workers-ai-provider": "^0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/ui-utils": "^1.2.11",
|
||||
"@cloudflare/vite-plugin": "^0.1.12",
|
||||
"@cloudflare/workers-types": "^4.20250317.0",
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"prettier": "^3.5.3",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"use-mcp": "link:../..",
|
||||
"vite": "^6.2.0",
|
||||
"wrangler": "^4.1.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977"
|
||||
}
|
||||
41
examples/chat-ui/playwright.config.ts
Normal file
41
examples/chat-ui/playwright.config.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'list',
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: 'http://localhost:5173',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue