diff --git a/.gitignore b/.gitignore index 022c1c54..74d4414a 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,4 @@ dmypy.json *.bak *.swp +.DS_Store diff --git a/mcp-agents/mcp-booking-main/.env.example b/mcp-agents/mcp-booking-main/.env.example new file mode 100644 index 00000000..f0330bde --- /dev/null +++ b/mcp-agents/mcp-booking-main/.env.example @@ -0,0 +1,51 @@ +# Google Maps API Configuration +# Get your API key from: https://console.cloud.google.com/ +# Required APIs: Places API, Places API (New), Geocoding API +GOOGLE_MAPS_API_KEY=your_google_maps_api_key_here + +# Yelp Fusion API (optional) โ€” adds Yelp profile + menu links on search results +# Create an app + API key: https://docs.developer.yelp.com/docs/fusion-intro +YELP_API_KEY= + +# Default Search Location (Taiwan) +# These coordinates will be used when latitude/longitude are not specified +DEFAULT_LATITUDE=xx +DEFAULT_LONGITUDE=xx + +# Optional: Server Configuration +PORT=3000 +# NODE_ENV=development + +# Optional: Set log level (debug, info, warn, error) +LOG_LEVEL=info + +# Optional: Default search radius in meters (default: 20000 = 20km) +DEFAULT_SEARCH_RADIUS=2000 + +# Restaurant recommendation agent (npm run agent) +AGENT_DEFAULT_PLACE=xx +AGENT_LOCALE=en +MCP_SERVER_URL=xx + +# Fetch.ai uAgent wrapper (python src/fetch_uagent.py) +FETCH_UAGENT_NAME=restaurant_recommendation_uagent +FETCH_UAGENT_PORT=8000 +FETCH_UAGENT_SEED=replace-this-with-your-own-strong-seed-phrase-please +FETCH_UAGENT_MAILBOX=true +# Optional public endpoint if self-hosting with a domain/IP +# FETCH_UAGENT_ENDPOINT=https://your-public-endpoint.example + +# Agent Payment Protocol + Stripe (after FREE_REQUEST_LIMIT successful chat requests) +# PAYWALL_SCOPE: global = one counter for all chats (fixes Agentverse changing sender per message). +# sender = count per uAgent `sender` (stricter multi-user). +PAYWALL_SCOPE=global +FREE_REQUEST_LIMIT=3 +STRIPE_SECRET_KEY= +STRIPE_PUBLISHABLE_KEY= +STRIPE_AMOUNT_CENTS=50 +STRIPE_CURRENCY=usd +STRIPE_PRODUCT_NAME=Restaurant discovery +STRIPE_SUCCESS_URL=https://agentverse.ai +# Paywall uses hosted Checkout first (clickable link in chat), then embedded as fallback. +# Stripe Checkout API ui_mode for embedded fallback (default embedded_page) +# STRIPE_API_UI_MODE=embedded_page diff --git a/mcp-agents/mcp-booking-main/.github/ISSUE_TEMPLATE/bug_report.md b/mcp-agents/mcp-booking-main/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..3b5a3aa2 --- /dev/null +++ b/mcp-agents/mcp-booking-main/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,57 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '[BUG] ' +labels: 'bug' +assignees: '' +--- + +## ๐Ÿ› Bug Description +A clear and concise description of what the bug is. + +## ๐Ÿ”„ To Reproduce +Steps to reproduce the behavior: +1. Call API endpoint '...' +2. With parameters '....' +3. See error + +## โœ… Expected Behavior +A clear and concise description of what you expected to happen. + +## ๐Ÿ–ผ๏ธ Screenshots +If applicable, add screenshots to help explain your problem. + +## ๐ŸŒ Environment +- Node.js version: [e.g. 18.17.0] +- npm version: [e.g. 9.6.7] +- Operating System: [e.g. macOS 13.4] +- MCP Booking version: [e.g. 1.0.0] + +## ๐Ÿ“‹ API Request Details (if applicable) +```json +{ + "searchParams": { + "location": { "latitude": 25.033, "longitude": 121.5654 }, + "cuisineTypes": ["Italian"], + "mood": "romantic", + "event": "dating" + } +} +``` + +## ๐Ÿ“Š Performance Impact +- [ ] Performance regression +- [ ] Memory leak +- [ ] Increased API calls +- [ ] Timeout issues +- [ ] Other: ___________ + +## ๐Ÿ”— Additional Context +Add any other context about the problem here. + +## ๐Ÿงช Test Case +If possible, provide a minimal test case that reproduces the issue. + +```typescript +// Test case code here +``` \ No newline at end of file diff --git a/mcp-agents/mcp-booking-main/.github/ISSUE_TEMPLATE/feature_request.md b/mcp-agents/mcp-booking-main/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..0e128d5e --- /dev/null +++ b/mcp-agents/mcp-booking-main/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,52 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: 'enhancement' +assignees: '' +--- + +## ๐Ÿš€ Feature Request + +### ๐Ÿ“ Summary +A clear and concise description of what the feature is. + +### ๐ŸŽฏ Motivation +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when... + +### ๐Ÿ’ก Proposed Solution +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +### ๐Ÿ”„ Alternatives Considered +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +### ๐Ÿ“Š Impact Assessment +- [ ] Performance improvement +- [ ] New API functionality +- [ ] UI/UX enhancement +- [ ] Developer experience improvement +- [ ] Integration with external services + +### ๐Ÿ—๏ธ Implementation Ideas +If you have ideas on how this could be implemented, please share them here. + +```typescript +// Pseudo-code or API design ideas +``` + +### ๐Ÿ“‹ Acceptance Criteria +- [ ] Criteria 1 +- [ ] Criteria 2 +- [ ] Criteria 3 + +### ๐Ÿงช Testing Requirements +How should this feature be tested? + +### ๐Ÿ“š Documentation Requirements +What documentation needs to be updated or created? + +### ๐Ÿ”— Additional Context +Add any other context or screenshots about the feature request here. \ No newline at end of file diff --git a/mcp-agents/mcp-booking-main/.github/dependabot.yml b/mcp-agents/mcp-booking-main/.github/dependabot.yml new file mode 100644 index 00000000..5f0889ce --- /dev/null +++ b/mcp-agents/mcp-booking-main/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "npm" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/mcp-agents/mcp-booking-main/.github/pull_request_template.md b/mcp-agents/mcp-booking-main/.github/pull_request_template.md new file mode 100644 index 00000000..bf633e73 --- /dev/null +++ b/mcp-agents/mcp-booking-main/.github/pull_request_template.md @@ -0,0 +1,46 @@ +## ๐Ÿ“‹ Pull Request Description + +### What does this PR do? + + +### Type of Change +- [ ] ๐Ÿ› Bug fix (non-breaking change which fixes an issue) +- [ ] โœจ New feature (non-breaking change which adds functionality) +- [ ] ๐Ÿ’ฅ Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] ๐Ÿ“š Documentation update +- [ ] ๐Ÿ”ง Refactoring (no functional changes) +- [ ] โšก Performance improvement +- [ ] ๐Ÿงช Test coverage improvement + +### ๐Ÿงช Testing +- [ ] Tests pass locally +- [ ] New tests added for new functionality +- [ ] Performance tests pass +- [ ] Integration tests pass + +### ๐Ÿ“ˆ Performance Impact + +- [ ] No performance impact +- [ ] Performance improvement +- [ ] Potential performance regression (explained below) + +### ๐Ÿ”— Related Issues + +Fixes #(issue_number) + +### ๐Ÿ“ท Screenshots/Videos + + +### ๐Ÿงฌ Additional Context + + +--- + +### โœ… Pre-flight Checklist +- [ ] Code follows the existing style guidelines +- [ ] Self-review of the code has been performed +- [ ] Code has been commented, particularly in hard-to-understand areas +- [ ] Corresponding changes to documentation have been made +- [ ] Changes generate no new warnings +- [ ] New and existing unit tests pass locally +- [ ] Any dependent changes have been merged and published \ No newline at end of file diff --git a/mcp-agents/mcp-booking-main/.github/workflows/ci.yml b/mcp-agents/mcp-booking-main/.github/workflows/ci.yml new file mode 100644 index 00000000..74e38f3b --- /dev/null +++ b/mcp-agents/mcp-booking-main/.github/workflows/ci.yml @@ -0,0 +1,107 @@ +name: CI Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + name: Test Suite + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x, 20.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Run type check + run: npm run build + + - name: Run tests + run: npm test + env: + GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY_TEST }} + + - name: Run tests with coverage + run: npm run test:coverage + env: + GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY_TEST }} + + - name: Upload coverage reports + uses: codecov/codecov-action@v3 + with: + file: ./coverage/lcov.info + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + build: + name: Build Check + runs-on: ubuntu-latest + needs: test + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + - name: Check build artifacts + run: | + if [ ! -d "dist" ]; then + echo "Build failed - dist directory not created" + exit 1 + fi + echo "Build successful - artifacts created" + ls -la dist/ + + security: + name: Security Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run security audit + run: npm audit --audit-level=high + + - name: Check for known vulnerabilities + run: npx audit-ci --config ./audit-ci.json + continue-on-error: true \ No newline at end of file diff --git a/mcp-agents/mcp-booking-main/.github/workflows/pr-checks.yml b/mcp-agents/mcp-booking-main/.github/workflows/pr-checks.yml new file mode 100644 index 00000000..74c14b14 --- /dev/null +++ b/mcp-agents/mcp-booking-main/.github/workflows/pr-checks.yml @@ -0,0 +1,124 @@ +name: PR Checks + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + issues: write + checks: write + +jobs: + pr-validation: + name: PR Validation + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch full history for better diff analysis + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check PR title format + uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + chore + requireScope: false + + - name: Run quality checks + run: npm run quality + + - name: Comment PR with test results + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + // Read benchmark results if available + let benchmarkResults = ''; + try { + benchmarkResults = fs.readFileSync('benchmark-results.txt', 'utf8'); + } catch (error) { + benchmarkResults = 'Benchmark results not available'; + } + + const comment = ` + ## ๐Ÿค– Automated PR Checks Results + + โœ… **Quality Checks**: Passed + โœ… **Build**: Successful + + ### Next Steps: + - All automated checks have passed + - Ready for human review + `; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + + code-quality: + name: Code Quality Analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run ESLint with annotations + run: | + npx eslint src/**/*.ts --format=@microsoft/eslint-formatter-sarif --output-file=eslint-results.sarif || true + + - name: Upload ESLint results to GitHub + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: eslint-results.sarif + wait-for-processing: true + + - name: Check test coverage + run: npm run test:coverage + env: + GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY_TEST }} + + - name: Coverage comment + uses: romeovs/lcov-reporter-action@v0.3.1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + lcov-file: ./coverage/lcov.info + delete-old-comments: true diff --git a/mcp-agents/mcp-booking-main/.gitignore b/mcp-agents/mcp-booking-main/.gitignore new file mode 100644 index 00000000..8a5c9d1d --- /dev/null +++ b/mcp-agents/mcp-booking-main/.gitignore @@ -0,0 +1,105 @@ +# Dependencies +node_modules/ +venv/ +.venv/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Python artifacts +__pycache__/ +*.pyc + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port \ No newline at end of file diff --git a/mcp-agents/mcp-booking-main/.prettierignore b/mcp-agents/mcp-booking-main/.prettierignore new file mode 100644 index 00000000..350940bc --- /dev/null +++ b/mcp-agents/mcp-booking-main/.prettierignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ +package-lock.json + +# Build outputs +dist/ +build/ + +# Environment files +.env +.env.local +.env.production + +# Logs +*.log + +# OS generated files +.DS_Store +Thumbs.db + +# IDE files +.vscode/ +.cursor/ + +# Git +.git/ + +# Documentation that should maintain specific formatting +CHANGELOG.md \ No newline at end of file diff --git a/mcp-agents/mcp-booking-main/.prettierrc b/mcp-agents/mcp-booking-main/.prettierrc new file mode 100644 index 00000000..363a0970 --- /dev/null +++ b/mcp-agents/mcp-booking-main/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "printWidth": 80, + "trailingComma": "es5", + "arrowParens": "avoid", + "endOfLine": "lf" +} diff --git a/mcp-agents/mcp-booking-main/DOCKER.md b/mcp-agents/mcp-booking-main/DOCKER.md new file mode 100644 index 00000000..c512cc31 --- /dev/null +++ b/mcp-agents/mcp-booking-main/DOCKER.md @@ -0,0 +1,49 @@ +# Docker deployment (MCP server) + +The **MCP HTTP server** (`src/index.ts`) is containerized. The Python **uAgent** (`src/fetch_uagent.py`) is not included in this image; run it separately on a host or VM that can reach this service. + +## Prerequisites + +- Docker 24+ and Docker Compose v2 +- A `.env` file (copy from `.env.example`) with at least **`GOOGLE_MAPS_API_KEY`** + +## Quick start + +```bash +cp .env.example .env +# Edit .env โ€” set GOOGLE_MAPS_API_KEY (required), optional YELP_API_KEY, etc. + +docker compose up -d --build +``` + +- **Health:** `http://localhost:${PORT:-3000}/health` +- **MCP endpoint:** `http://localhost:${PORT:-3000}/mcp` + +The compose file maps **host** port `${PORT:-3000}` โ†’ container port **3000**. The process inside the container always listens on `3000` (see `docker-compose.yml` `environment.PORT`). + +## Point the TypeScript agent / uAgent at Docker + +On the machine running `fetch_uagent.py` or any MCP client, set: + +```bash +MCP_SERVER_URL=http://:3000/mcp +``` + +Examples: + +- MCP on same machine, uAgent on host: `http://127.0.0.1:3000/mcp` +- MCP in Docker, uAgent on host (Docker Desktop): `http://127.0.0.1:3000/mcp` (published port) +- uAgent in another container on the same Compose project: add both services to `docker-compose.yml` and use `http://mcp:3000/mcp` on the internal network (not covered by default file; extend as needed) + +## Build image only + +```bash +docker build -t mcp-restaurant-booking:latest . +docker run --rm -p 3000:3000 --env-file .env mcp-restaurant-booking:latest +``` + +## Production notes + +- Do not bake `.env` into images; pass secrets via Compose `env_file`, orchestrator secrets, or `-e`. +- Use TLS termination (reverse proxy) in front of the container for public deployments. +- Rotate API keys if they were ever committed or logged. diff --git a/mcp-agents/mcp-booking-main/Dockerfile b/mcp-agents/mcp-booking-main/Dockerfile new file mode 100644 index 00000000..9c86c3cf --- /dev/null +++ b/mcp-agents/mcp-booking-main/Dockerfile @@ -0,0 +1,35 @@ +# Build stage +FROM node:20-slim AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src ./src + +RUN npm run build + +# Production stage +FROM node:20-slim AS production + +WORKDIR /app + +COPY package*.json ./ +RUN npm pkg delete scripts.prepare 2>/dev/null || true && \ + npm ci --omit=dev && \ + npm cache clean --force + +COPY --from=builder /app/dist ./dist + +ENV NODE_ENV=production +ENV PORT=3000 + +EXPOSE 3000 + +# MCP HTTP + /health (see src/index.ts) +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "dist/index.js"] diff --git a/mcp-agents/mcp-booking-main/LICENSE b/mcp-agents/mcp-booking-main/LICENSE new file mode 100644 index 00000000..eec928c4 --- /dev/null +++ b/mcp-agents/mcp-booking-main/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Sam Wang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mcp-agents/mcp-booking-main/README.md b/mcp-agents/mcp-booking-main/README.md new file mode 100644 index 00000000..fbe7baaa --- /dev/null +++ b/mcp-agents/mcp-booking-main/README.md @@ -0,0 +1,344 @@ +# Restaurant Booking MCP Server + +An AI-powered Model Context Protocol (MCP) server for restaurant discovery and booking. This server integrates with Google Maps Places API to find restaurants based on location, cuisine preferences, mood, and event type, then provides intelligent recommendations and booking assistance. + +## ๐ŸŽฏ Key Features + +- **Smart Restaurant Search**: Find restaurants within 20km radius with advanced filtering +- **Default Taiwan Location**: Automatically searches around Taiwan (24.1501164, 120.6692299) when no coordinates specified +- **AI-Powered Recommendations**: Get top 3 restaurant suggestions with detailed reasoning +- **Google Maps Integration**: Real restaurant data including ratings, reviews, and photos +- **Event-Specific Matching**: Optimized for dating, family gatherings, business meetings, and celebrations +- **Mood-Based Filtering**: Find restaurants matching romantic, casual, upscale, fun, or quiet atmospheres +- **Booking Assistance**: Get reservation instructions and mock booking capabilities + +## Features + +- ๐Ÿ” **Smart Restaurant Search**: Find restaurants within 20km radius based on location, cuisine types, mood, and event type +- ๐Ÿ“ **Google Maps Integration**: Real restaurant data with ratings, reviews, photos, and contact information +- ๐Ÿ“… **Booking Assistance**: Check availability and get reservation instructions +- ๐ŸŽฏ **Event-Specific Matching**: Optimized recommendations for dating, family gatherings, business meetings, etc. +- ๐ŸŽญ **Mood-Based Filtering**: Find restaurants that match your desired atmosphere (romantic, casual, upscale, etc.) + +## Prerequisites + +- Node.js 18+ +- Google Maps API Key with Places API enabled +- TypeScript knowledge for customization + +## Installation + +1. **Clone or download this project** + + ```bash + git clone + cd mcp-restaurant-booking + ``` + +2. **Install dependencies** + + ```bash + npm install + ``` + +3. **Set up environment variables** + + ```bash + cp .env.example .env + ``` + + Edit `.env` and add your Google Maps API key: + + ``` + GOOGLE_MAPS_API_KEY=your_actual_api_key_here + ``` + +4. **Build the project** + ```bash + npm run build + ``` + +## Getting Google Maps API Key + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select existing one +3. Enable the following APIs: + - Places API + - Maps JavaScript API + - Geolocation API + - Places API (New) + - Geocoding API +4. Create credentials (API Key) +5. Restrict the API key to the enabled APIs for security + +## Usage + +### Running the Server + +**Development mode:** + +```bash +npm run dev +``` + +**Production mode:** + +```bash +npm start +``` + +## Running in Docker + +To run the MCP Restaurant Booking server in Docker: + +```bash +# Build the Docker image +docker build -t mcp/booking . + +# Run the container on the same network as Redis +docker run --rm -i mcp/booking +``` + +### Available Tools + +The MCP server provides the following tools: + +#### 1. `search_restaurants` + +Find restaurants based on location, cuisine, mood, and event type. + +**Parameters:** + +- `latitude` (number, optional): Search latitude (default: 24.1501164 - Taiwan) +- `longitude` (number, optional): Search longitude (default: 120.6692299 - Taiwan) +- `placeName` (string, optional): Place name to search near (e.g., "New York", "Tokyo", "London"). Alternative to providing latitude/longitude coordinates. +- `cuisineTypes` (string[]): Array of cuisine preferences +- `mood` (string): Desired atmosphere +- `event` (string): Type of occasion +- `radius` (number, optional): Search radius in meters (default: 20000) +- `priceLevel` (number, optional): Price preference (1-4) + +**Example with default Taiwan location:** + +```json +{ + "cuisineTypes": ["Chinese", "Taiwanese"], + "mood": "casual", + "event": "family gathering", + "priceLevel": 2 +} +``` + +**Example with explicit coordinates (Taipei):** + +```json +{ + "latitude": 25.033, + "longitude": 121.5654, + "cuisineTypes": ["Italian", "Mediterranean"], + "mood": "romantic", + "event": "dating", + "radius": 15000, + "priceLevel": 3 +} +``` + +**Example with place name (New York):** + +```json +{ + "placeName": "New York, NY", + "cuisineTypes": ["Italian", "American"], + "mood": "upscale", + "event": "business meeting", + "radius": 10000, + "priceLevel": 3 +} +``` + +**Example with keyword search for specific food types:** + +```json +{ + "keyword": "hotpot", + "mood": "casual", + "event": "family gathering", + "radius": 10000 +} +``` + +#### 2. `get_restaurant_details` + +Get detailed information about a specific restaurant. + +**Parameters:** + +- `placeId` (string): Google Places ID of the restaurant + +#### 3. `get_booking_instructions` + +Get instructions on how to make a reservation. + +**Parameters:** + +- `placeId` (string): Google Places ID of the restaurant + +#### 4. `check_availability` + +Check availability for a reservation (mock implementation). + +**Parameters:** + +- `placeId` (string): Google Places ID +- `dateTime` (string): Preferred date/time in ISO format +- `partySize` (number): Number of people + +#### 5. `make_reservation` + +Attempt to make a reservation (mock implementation). + +**Parameters:** + +- `placeId` (string): Google Places ID +- `partySize` (number): Number of people +- `preferredDateTime` (string): ISO format date/time +- `contactName` (string): Name for reservation +- `contactPhone` (string): Phone number +- `contactEmail` (string, optional): Email address +- `specialRequests` (string, optional): Special requests + +## How It Works + +### 1. Restaurant Discovery + +- Uses Google Places Nearby Search API to find restaurants within specified radius +- Filters by cuisine types using keyword matching +- Retrieves detailed information for each restaurant + +### 2. AI Recommendation Engine + +The recommendation system scores restaurants based on: + +- **Rating & Reviews (40% weight)**: Higher ratings and more reviews = better score +- **Review Count (20% weight)**: More reviews indicate reliability +- **Cuisine Match (20% weight)**: How well restaurant cuisine matches preferences +- **Event Suitability (10% weight)**: Appropriateness for the specified event type +- **Mood Match (10% weight)**: Atmosphere alignment with desired mood + +### 3. Event-Specific Scoring + +Different events have different criteria: + +- **Dating**: Prefers mid-to-high-end, romantic cuisines, avoids fast food +- **Family Gathering**: Prefers family-friendly, budget-to-mid-range options +- **Business Meeting**: Prefers quiet, professional, upscale environments +- **Casual Dining**: Flexible criteria, budget-friendly options +- **Celebration**: Prefers high-end, special occasion venues + +### 4. Mood Matching + +Analyzes restaurant names, reviews, and characteristics for mood keywords: + +- **Romantic**: intimate, cozy, candlelit, wine +- **Casual**: relaxed, friendly, laid-back +- **Upscale**: elegant, sophisticated, fine dining +- **Fun**: lively, energetic, vibrant +- **Quiet**: peaceful, serene, calm + +## Development + +### Project Structure + +``` +src/ +โ”œโ”€โ”€ types/ # TypeScript type definitions +โ”œโ”€โ”€ services/ # Core business logic +โ”‚ โ”œโ”€โ”€ googleMapsService.ts # Google Maps API integration +โ”‚ โ”œโ”€โ”€ restaurantRecommendationService.ts # AI recommendation engine +โ”‚ โ””โ”€โ”€ bookingService.ts # Booking logic (mock) +โ””โ”€โ”€ index.ts # MCP server implementation +``` + +### Scripts + +- `npm run build`: Compile TypeScript +- `npm run dev`: Run in development mode with hot reload +- `npm start`: Run compiled version +- `npm run lint`: Run ESLint +- `npm run lint:fix`: Fix ESLint issues + +### Customization + +#### Adding New Cuisine Types + +Edit the `cuisineMap` in `src/services/googleMapsService.ts`: + +```typescript +const cuisineMap: { [key: string]: string } = { + new_cuisine_type: "Display Name", + // ... existing mappings +}; +``` + +#### Modifying Recommendation Logic + +Update scoring algorithms in `src/services/restaurantRecommendationService.ts`: + +- `calculateRestaurantScore()`: Overall scoring logic +- `calculateEventSuitability()`: Event-specific criteria +- `calculateMoodMatch()`: Mood matching logic + +#### Adding New Event Types + +1. Update the `event` enum in `src/types/index.ts` +2. Add event criteria in `calculateEventSuitability()` method + +## Limitations + +- **Booking**: Currently uses mock implementation. Real booking requires integration with restaurant-specific systems or third-party services like OpenTable +- **API Quotas**: Google Places API has usage limits and costs +- **Real-time Data**: Restaurant hours and availability may not be real-time +- **Geographic Coverage**: Limited to areas covered by Google Places API + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +## License + +MIT License - see LICENSE file for details + +## Support + +For issues and questions: + +1. Check the Google Maps API documentation +2. Verify your API key has proper permissions +3. Check API quotas and billing +4. Review server logs for error details + +## Future Enhancements + +- [ ] Real booking system integration (OpenTable, Resy, etc.) +- [ ] User preference learning +- [x] Multi-language support +- [ ] Advanced filtering (dietary restrictions, accessibility) +- [ ] Integration with calendar systems +- [x] Price comparison features +- [ ] Social features (reviews, sharing) + +## Additional Browser Control + +Using Browser MCP + +- https://chromewebstore.google.com/detail/browser-mcp-automate-your/bjfgambnhccakkhmkepdoekmckoijdlc +- https://docs.browsermcp.io/setup-server#cursor + +## Sample + +- Prompt: - While searching restaurants, please perform as professional personal assistant to evaluate the condition I provided, do not ask too many questions for me to choose, pick the best suitable selection for me, checking the reservation options and guide how to do the reservation. also list down the Signature Dishes from that restaurant and Approximately pricing per person. When booking info has booking url using external url, use the mcp browse tool to work and find reservation steps. +- can you help me book a restaurant nearby hongkong ๅคชๅนณๆด‹ๅปฃๅ ด, I want to have a date with my wife within a fine-dining at evening 6pm. cost is not a concern and needs to be romatic diff --git a/mcp-agents/mcp-booking-main/SETUP.md b/mcp-agents/mcp-booking-main/SETUP.md new file mode 100644 index 00000000..eeb97550 --- /dev/null +++ b/mcp-agents/mcp-booking-main/SETUP.md @@ -0,0 +1,144 @@ +# Quick Setup Guide + +## ๐Ÿš€ Get Started in 5 Minutes + +### 1. Install Dependencies + +```bash +npm install +``` + +### 2. Get Google Maps API Key + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select existing one +3. Enable these APIs: + - Places API + - Places API (New) + - Geocoding API + - Geolocation API + - Maps Javascript API +4. Create an API Key +5. Restrict the key to the enabled APIs + +### 3. Configure Environment + +```bash +cp .env.example .env +``` + +Edit `.env` and add your API key: + +``` +GOOGLE_MAPS_API_KEY=your_actual_api_key_here +``` + +### 4. Build and Run + +```bash +npm run build +npm start +``` + +## ๐Ÿงช Test the Server + +Run the test script: + +```bash +node test-server.js +``` + +## ๐Ÿ”ง Available Tools + +1. **search_restaurants** - Find restaurants with AI recommendations +2. **get_restaurant_details** - Get detailed restaurant information +3. **get_booking_instructions** - Get reservation instructions +4. **check_availability** - Check table availability (mock) +5. **make_reservation** - Make a reservation (mock) + +## ๐Ÿ“– Usage Examples + +### Find Restaurants in Taiwan (Default Location) + +```json +{ + "cuisineTypes": ["Chinese", "Taiwanese"], + "mood": "casual", + "event": "family gathering", + "priceLevel": 2 +} +``` + +### Find Romantic Restaurants in Taipei + +```json +{ + "latitude": 25.033, + "longitude": 121.5654, + "cuisineTypes": ["Italian", "French"], + "mood": "romantic", + "event": "dating", + "priceLevel": 3 +} +``` + +### Family-Friendly Options (Custom Location) + +```json +{ + "latitude": 40.7128, + "longitude": -74.006, + "cuisineTypes": ["American", "Italian"], + "mood": "casual", + "event": "family gathering", + "priceLevel": 2 +} +``` + +## ๐ŸŽฏ Key Features + +- **Smart Search**: 20km radius with cuisine, mood, and event filtering +- **AI Recommendations**: Top 3 suggestions with detailed reasoning +- **Real Data**: Google Maps integration with ratings, reviews, photos +- **Event Matching**: Optimized for dating, family, business, celebrations +- **Mood Filtering**: Romantic, casual, upscale, fun, quiet atmospheres + +## ๐Ÿ” How It Works + +1. **Search**: Uses Google Places API to find restaurants +2. **Analyze**: AI scores based on rating, cuisine match, event suitability, mood +3. **Rank**: Returns top 3 with detailed reasoning +4. **Book**: Provides reservation instructions and mock booking + +## ๐Ÿ’ก Tips + +- Use specific cuisine types for better results +- Match mood to event type (romantic + dating, casual + family) +- Set appropriate price level for your budget +- Check multiple options before deciding + +## ๐Ÿšจ Troubleshooting + +**"API Key Required" Error:** + +- Make sure `.env` file exists with valid `GOOGLE_MAPS_API_KEY` +- Verify API key has Places API enabled +- Check API key restrictions + +**No Results Found:** + +- Increase search radius +- Try broader cuisine types +- Check if location coordinates are valid + +**Build Errors:** + +- Run `npm install` to ensure all dependencies are installed +- Check Node.js version (requires 18+) + +## ๐Ÿ“š Next Steps + +1. See `examples/usage-examples.md` for detailed examples +2. Read `README.md` for complete documentation +3. Customize recommendation logic in `src/services/` +4. Add real booking integration for production use diff --git a/mcp-agents/mcp-booking-main/eslint.config.js b/mcp-agents/mcp-booking-main/eslint.config.js new file mode 100644 index 00000000..f97bc3e9 --- /dev/null +++ b/mcp-agents/mcp-booking-main/eslint.config.js @@ -0,0 +1,54 @@ +import eslint from '@eslint/js'; +import tseslint from '@typescript-eslint/eslint-plugin'; +import tsparser from '@typescript-eslint/parser'; +import globals from 'globals'; + +export default [ + eslint.configs.recommended, + { + files: ['src/**/*.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + globals: { + ...globals.node, + NodeJS: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + 'prefer-const': 'error', + 'no-var': 'error', + 'no-unused-vars': 'off', // Let TypeScript handle this + }, + }, + { + files: ['src/**/*.test.ts', 'src/tests/**/*.ts'], + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + performance: 'readonly', + test: 'readonly', + NodeJS: 'readonly', + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', // Allow any in tests for mocking + }, + }, +]; diff --git a/mcp-agents/mcp-booking-main/examples/usage-examples.md b/mcp-agents/mcp-booking-main/examples/usage-examples.md new file mode 100644 index 00000000..e275d683 --- /dev/null +++ b/mcp-agents/mcp-booking-main/examples/usage-examples.md @@ -0,0 +1,271 @@ +# Usage Examples + +This document provides practical examples of how to use the Restaurant Booking MCP Server. + +## Example 1: Romantic Date Night + +Find restaurants for a romantic dinner date in San Francisco: + +```json +{ + "tool": "search_restaurants", + "parameters": { + "latitude": 37.7749, + "longitude": -122.4194, + "cuisineTypes": ["Italian", "French", "Mediterranean"], + "mood": "romantic", + "event": "dating", + "priceLevel": 3, + "radius": 15000 + } +} +``` + +**Expected Response:** + +- Top 3 restaurants with romantic atmosphere +- High ratings and upscale ambiance +- Italian/French/Mediterranean cuisine focus +- Mid-to-high price range + +## Example 2: Family Gathering + +Find family-friendly restaurants for a weekend gathering: + +```json +{ + "tool": "search_restaurants", + "parameters": { + "latitude": 40.7128, + "longitude": -74.006, + "cuisineTypes": ["American", "Italian", "Mexican"], + "mood": "casual", + "event": "family gathering", + "priceLevel": 2, + "radius": 20000 + } +} +``` + +**Expected Response:** + +- Family-friendly restaurants +- Casual atmosphere +- Budget-to-moderate pricing +- Spacious seating arrangements + +## Example 3: Business Meeting + +Find upscale restaurants suitable for business discussions: + +```json +{ + "tool": "search_restaurants", + "parameters": { + "latitude": 34.0522, + "longitude": -118.2437, + "cuisineTypes": ["American", "Steakhouse"], + "mood": "upscale", + "event": "business meeting", + "priceLevel": 4, + "radius": 10000 + } +} +``` + +**Expected Response:** + +- Professional, quiet atmosphere +- High-end restaurants +- Suitable for business conversations +- Premium pricing + +## Example 4: Get Restaurant Details + +Get detailed information about a specific restaurant: + +```json +{ + "tool": "get_restaurant_details", + "parameters": { + "placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4" + } +} +``` + +**Response includes:** + +- Complete restaurant information +- Reviews and ratings +- Photos +- Opening hours +- Contact information + +## Example 5: Check Availability + +Check if a restaurant has availability for your preferred time: + +```json +{ + "tool": "check_availability", + "parameters": { + "placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4", + "dateTime": "2024-02-14T19:00:00", + "partySize": 2 + } +} +``` + +**Response includes:** + +- Availability status +- Alternative time suggestions if unavailable +- Booking recommendations + +## Example 6: Make a Reservation + +Attempt to make a reservation (mock implementation): + +```json +{ + "tool": "make_reservation", + "parameters": { + "placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4", + "partySize": 4, + "preferredDateTime": "2024-02-15T18:30:00", + "contactName": "John Smith", + "contactPhone": "+1-555-123-4567", + "contactEmail": "john.smith@email.com", + "specialRequests": "Window table preferred, celebrating anniversary" + } +} +``` + +**Response includes:** + +- Booking confirmation or failure +- Reservation details +- Confirmation number +- Alternative options if unsuccessful + +## Example 7: Get Booking Instructions + +Get instructions on how to make a reservation at a restaurant: + +```json +{ + "tool": "get_booking_instructions", + "parameters": { + "placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4" + } +} +``` + +**Response includes:** + +- Phone number for reservations +- Website booking links +- Opening hours +- Special booking notes + +## Example 8: Search by Place Name + +Find restaurants using a place name instead of coordinates: + +```json +{ + "tool": "search_restaurants", + "parameters": { + "placeName": "Tokyo, Japan", + "cuisineTypes": ["Japanese", "Sushi"], + "mood": "upscale", + "event": "business meeting", + "priceLevel": 3, + "radius": 5000 + } +} +``` + +**Expected Response:** + +- Restaurants near Tokyo city center +- High-quality Japanese cuisine +- Professional atmosphere suitable for business +- Mid-to-high price range + +**Alternative place name examples:** + +- `"New York, NY"` - Search in New York City +- `"London, UK"` - Search in London +- `"Paris, France"` - Search in Paris +- `"Sydney, Australia"` - Search in Sydney +- `"Times Square, New York"` - Search near specific landmark + +## Common Use Cases + +### 1. Date Night Planning + +```bash +# Step 1: Search for romantic restaurants +search_restaurants -> romantic Italian restaurants + +# Step 2: Get details for top choice +get_restaurant_details -> full restaurant info + +# Step 3: Check availability +check_availability -> confirm time slot + +# Step 4: Get booking instructions +get_booking_instructions -> how to reserve +``` + +### 2. Group Dining + +```bash +# Step 1: Search for family-friendly options +search_restaurants -> casual, large party suitable + +# Step 2: Compare multiple options +get_restaurant_details -> for each top choice + +# Step 3: Check availability for large group +check_availability -> party size 8+ + +# Step 4: Make reservation +make_reservation -> book the table +``` + +### 3. Business Entertainment + +```bash +# Step 1: Find upscale, quiet restaurants +search_restaurants -> business meeting suitable + +# Step 2: Verify atmosphere and amenities +get_restaurant_details -> check reviews for business suitability + +# Step 3: Book appropriate time +check_availability -> lunch or dinner slot + +# Step 4: Reserve with special requests +make_reservation -> quiet table, business atmosphere +``` + +## Tips for Best Results + +1. **Be Specific with Cuisine Types**: Use specific cuisines like "Italian", "Japanese" rather than generic terms +2. **Match Mood to Event**: Align mood (romantic, casual, upscale) with event type for better recommendations +3. **Consider Price Level**: Set appropriate price level for your budget and occasion +4. **Use Reasonable Radius**: 10-20km radius typically provides good options without being too broad +5. **Check Multiple Options**: Get details for all top 3 recommendations before deciding +6. **Plan Ahead**: Check availability well in advance for popular restaurants and peak times + +## Error Handling + +The server handles various error conditions gracefully: + +- **Invalid API Key**: Returns error message about authentication +- **No Results Found**: Suggests expanding search criteria +- **Invalid Location**: Prompts for valid latitude/longitude +- **Restaurant Not Found**: Returns appropriate error for invalid place IDs +- **Booking Failures**: Provides alternative suggestions and manual booking instructions diff --git a/mcp-agents/mcp-booking-main/jest.config.js b/mcp-agents/mcp-booking-main/jest.config.js new file mode 100644 index 00000000..140455fd --- /dev/null +++ b/mcp-agents/mcp-booking-main/jest.config.js @@ -0,0 +1,33 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: [ + '**/__tests__/**/*.(ts|js)', + '**/*.(test|spec).(ts|js)' + ], + transform: { + '^.+\\.ts$': 'ts-jest' + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/tests/**', + '!src/index.ts' + ], + coverageDirectory: 'coverage', + coverageReporters: [ + 'text', + 'lcov', + 'html' + ], + setupFilesAfterEnv: ['/src/tests/setup.ts'], + testTimeout: 30000, + maxWorkers: '50%', + // Performance monitoring + verbose: true, + reporters: ['default'] +}; \ No newline at end of file diff --git a/mcp-agents/mcp-booking-main/package.json b/mcp-agents/mcp-booking-main/package.json new file mode 100644 index 00000000..9cce5c19 --- /dev/null +++ b/mcp-agents/mcp-booking-main/package.json @@ -0,0 +1,63 @@ +{ + "name": "mcp-restaurant-booking", + "version": "1.0.0", + "description": "MCP server for restaurant booking with AI-powered recommendations", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "dev": "tsx src/index.ts", + "agent": "tsx src/agent/cli.ts", + "start": "node dist/index.js", + "lint": "eslint src/**/*.ts", + "lint:fix": "eslint src/**/*.ts --fix", + "type-check": "tsc --noEmit", + "format:check": "prettier --check src/**/*.ts", + "format": "prettier --write src/**/*.ts", + "quality": "npm run type-check && npm run lint && npm run format:check", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage", + "test:integration": "jest --testPathPattern=integration", + "test:unit": "jest --testPathPattern='(googleMapsService|restaurantRecommendationService|bookingService).test'" + }, + "keywords": [ + "mcp", + "restaurant", + "booking", + "ai", + "google-maps" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@googlemaps/google-maps-services-js": "^3.4.2", + "@googlemaps/places": "^2.1.0", + "@modelcontextprotocol/sdk": "^1.17.0", + "axios": "^1.6.0", + "dotenv": "^17.2.1", + "express": "^5.1.0", + "form-data": "^4.0.4", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "devDependencies": { + "@eslint/js": "^9.32.0", + "@microsoft/eslint-formatter-sarif": "^3.1.0", + "@types/express": "^5.0.3", + "@types/jest": "^29.5.14", + "@types/node": "^20.10.0", + "@typescript-eslint/eslint-plugin": "^8.38.0", + "@typescript-eslint/parser": "^8.38.0", + "audit-ci": "^7.1.0", + "eslint": "^9.32.0", + "globals": "^15.12.0", + "jest": "^29.7.0", + "jest-junit": "^16.0.0", + "prettier": "^3.6.2", + "prettier-eslint": "^16.4.2", + "ts-jest": "^29.4.0", + "tsx": "^4.6.0", + "typescript": "^5.3.0" + } +} diff --git a/mcp-agents/mcp-booking-main/requirements-fetch-uagent.txt b/mcp-agents/mcp-booking-main/requirements-fetch-uagent.txt new file mode 100644 index 00000000..cf4a313c --- /dev/null +++ b/mcp-agents/mcp-booking-main/requirements-fetch-uagent.txt @@ -0,0 +1,3 @@ +uagents>=0.18.0 +stripe>=11.0.0 +python-dotenv>=1.0.0 diff --git a/mcp-agents/mcp-booking-main/src/agent/cli.ts b/mcp-agents/mcp-booking-main/src/agent/cli.ts new file mode 100644 index 00000000..600352d3 --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/agent/cli.ts @@ -0,0 +1,25 @@ +#!/usr/bin/env node +/** + * Local CLI for the recommendation agent. + * Usage: npm run agent -- "romantic Italian dinner in San Jose" + * Requires MCP server running (npm run dev) and GOOGLE_MAPS_API_KEY in .env + */ +import dotenv from 'dotenv'; +import { respondToUserQuery } from './recommendationAgent.js'; + +dotenv.config(); + +const query = process.argv.slice(2).join(' ').trim(); +if (!query) { + console.error('Usage: npm run agent -- ""'); + process.exit(1); +} + +respondToUserQuery(query) + .then(out => { + console.log(out); + }) + .catch(err => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }); diff --git a/mcp-agents/mcp-booking-main/src/agent/formatRecommendationPicks.ts b/mcp-agents/mcp-booking-main/src/agent/formatRecommendationPicks.ts new file mode 100644 index 00000000..64c51356 --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/agent/formatRecommendationPicks.ts @@ -0,0 +1,43 @@ +import type { SearchRecommendationsPayload } from './types.js'; +import { resolvePhotoDisplayUrl } from './photoDisplayUrl.js'; + +function priceLevelToLabel(level?: number): string { + if (level === undefined || level === null) return 'Not listed'; + const n = Math.min(4, Math.max(0, Math.round(level))); + if (n <= 0) return 'Not listed'; + return `${'$'.repeat(n)} (${n}/4)`; +} + +/** + * Human-friendly, non-JSON output for chat / Agentverse. + */ +export function formatRecommendationPicks( + payload: SearchRecommendationsPayload, + topN = 3 +): string { + const picks = payload.recommendations.slice(0, Math.min(topN, 3)); + if (picks.length === 0) { + return 'No matches right now โ€” try another area or cuisine.'; + } + + const lines: string[] = ['๐Ÿฝ๏ธ Top Restaurant Picks:', '']; + + picks.forEach((rec, i) => { + const r = rec.restaurant; + const reviews = + r.userRatingsTotal > 0 ? ` (${r.userRatingsTotal.toLocaleString()} reviews)` : ''; + lines.push(`${i + 1}. ${r.name}`); + lines.push(` โญ ${r.rating.toFixed(1)}${reviews}`); + lines.push(` ๐Ÿ’ฐ ${priceLevelToLabel(r.priceLevel)}`); + lines.push(` โœจ Why: ${rec.reasoning}`); + const img = r.photos?.length + ? resolvePhotoDisplayUrl(r.photos[0] ?? '') + : null; + if (img) { + lines.push(` ![${r.name}](${img})`); + } + lines.push(''); + }); + + return lines.join('\n').trimEnd(); +} diff --git a/mcp-agents/mcp-booking-main/src/agent/formatRestaurantDetails.ts b/mcp-agents/mcp-booking-main/src/agent/formatRestaurantDetails.ts new file mode 100644 index 00000000..e118d15c --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/agent/formatRestaurantDetails.ts @@ -0,0 +1,243 @@ +import type { SearchRecommendationsPayload } from './types.js'; +import { resolvePhotoDisplayUrl } from './photoDisplayUrl.js'; + +/** Public Yelp search โ€” works without YELP_API_KEY (Fusion only adds direct menu/biz URLs). */ +function yelpSearchResultsUrl( + businessName: string, + address: string, + searchLocation: string +): string { + const findDesc = encodeURIComponent(businessName.trim()); + let loc = searchLocation.trim(); + if (loc && !loc.includes(',') && /\b(CA|California)\b/i.test(address)) { + loc = `${loc}, CA`; + } + const findLoc = encodeURIComponent(loc || address); + return `https://www.yelp.com/search?find_desc=${findDesc}&find_loc=${findLoc}`; +} + +function scoreNameMatch(target: string, candidate: string): number { + const t = target.toLowerCase().trim(); + const c = candidate.toLowerCase().trim(); + if (t === c) return 1000; + if (c.includes(t)) return 800; + if (t.includes(c)) return 600; + const tWords = new Set(t.split(/\s+/).filter(Boolean)); + const cWords = new Set(c.split(/\s+/).filter(Boolean)); + let overlap = 0; + for (const w of tWords) { + if (cWords.has(w)) overlap += 1; + } + return overlap * 50; +} + +export function pickBestRestaurantForName( + payload: SearchRecommendationsPayload, + restaurantName: string +) { + const ranked = payload.recommendations + .map(rec => { + const nameScore = scoreNameMatch(restaurantName, rec.restaurant.name); + const qualityScore = rec.restaurant.rating * 10 + Math.min(20, rec.restaurant.userRatingsTotal / 100); + return { rec, total: nameScore + qualityScore }; + }) + .sort((a, b) => b.total - a.total); + return ranked[0]?.rec; +} + +/** + * Agentverse / many chat UIs collapse single `\n` into a space. + * Join logical lines with blank lines so each survives as its own paragraph. + */ +function paragraphs(parts: Array): string { + return parts + .map(p => (p ?? '').trim()) + .filter(Boolean) + .join('\n\n'); +} + +export function formatRestaurantDetails( + payload: SearchRecommendationsPayload, + restaurantName: string, + searchLocation: string, + focus: 'details' | 'menu' = 'details' +): string { + const best = pickBestRestaurantForName(payload, restaurantName); + if (!best) { + return paragraphs([ + `Couldn't find "${restaurantName}" near "${searchLocation}".`, + `Tip: add the city, e.g. "details for ${restaurantName} in San Jose".`, + ]); + } + + const r = best.restaurant; + const openNow = + r.openingHours?.openNow === true + ? 'Yes' + : r.openingHours?.openNow === false + ? 'No' + : 'No / not listed'; + + const blocks: string[] = []; + + blocks.push( + [ + `๐Ÿ“ Restaurant Details: ${r.name}`, + `๐Ÿ  Address ${r.address}`, + ].join('\n') + ); + + if (focus === 'menu') { + const menuLines: string[] = ['๐Ÿฑ Menu on Yelp']; + const yelpSearch = yelpSearchResultsUrl(r.name, r.address, searchLocation); + if (r.yelpMenuUrl) { + menuLines.push(''); + menuLines.push('Menu:'); + menuLines.push(r.yelpMenuUrl); + } else { + menuLines.push( + `Search Yelp (opens results; pick the listing to see menu/photos): ${yelpSearch}` + ); + if (r.website) { + menuLines.push(`Restaurant website (may include a menu): ${r.website}`); + } + menuLines.push( + 'Direct Yelp menu link not available right now (common reasons: Yelp API access limits/trial status, or no exact Yelp match).' + ); + } + blocks.push(menuLines.join('\n')); + } + + blocks.push( + [ + 'โญ Review Summary', + `Rating: ${r.rating.toFixed(1)} / 5`, + `Reviews: ${r.userRatingsTotal.toLocaleString()}`, + '', + `Open now: ${openNow}`, + ].join('\n') + ); + + const contactLines = [ + '๐Ÿ“ž Contact & Links', + `Phone: ${r.phoneNumber || 'Not listed'}`, + `Website / Reservation: ${r.website || 'Not listed'}`, + '', + 'Google Maps:', + `${r.googleMapsUrl || 'Not listed'}`, + ]; + if (focus !== 'menu') { + if (r.yelpMenuUrl) { + contactLines.push(''); + contactLines.push('Menu (Yelp):'); + contactLines.push(r.yelpMenuUrl); + } + } + blocks.push(contactLines.join('\n')); + + const photosRaw = r.photos?.slice(0, 3) || []; + const photoUrls = photosRaw + .map(resolvePhotoDisplayUrl) + .filter((u): u is string => Boolean(u)); + if (photoUrls.length > 0) { + const md = photoUrls + .map((u, idx) => `![${r.name} โ€” photo ${idx + 1}](${u})`) + .join('\n\n'); + blocks.push(['๐Ÿ“ธ Photos', md].join('\n\n')); + } + + const weekday = r.openingHours?.weekdayText || []; + blocks.push( + ['๐Ÿ•’ Opening Hours', weekday.length ? weekday.join('\n') : 'Not listed'].join('\n') + ); + + // Intentionally omit recent customer reviews block for cleaner output. + + return paragraphs(blocks); +} + +export type RestaurantDetailsIntent = { + restaurantName: string; + searchLocation: string; + focus?: 'details' | 'menu'; +}; + +export function parseRestaurantDetailsIntent(input: string): RestaurantDetailsIntent | null { + const txt = input.trim(); + const fallbackLocation = process.env.AGENT_DEFAULT_PLACE || 'San Jose'; + + const menuWithLocation = [ + /(?:show\s+)?(?:the\s+)?menu\s+for\s+(.+?)\s+in\s+(.+)/i, + /^menu\s+for\s+(.+?)\s+in\s+(.+)/i, + ]; + for (const p of menuWithLocation) { + const m = txt.match(p); + if (m?.[1] && m?.[2]) { + return { + restaurantName: m[1].trim(), + searchLocation: m[2].trim(), + focus: 'menu', + }; + } + } + + const menuNoLocation = [ + /(?:show\s+)?(?:the\s+)?menu\s+for\s+(.+)/i, + /^menu\s+for\s+(.+)/i, + ]; + for (const p of menuNoLocation) { + const m = txt.match(p); + if (m?.[1]) { + return { + restaurantName: m[1].trim(), + searchLocation: fallbackLocation, + focus: 'menu', + }; + } + } + + const patterns = [ + /show details for restaurant named\s+(.+?)\s+in\s+(.+)/i, + /show details for\s+(.+?)\s+in\s+(.+)/i, + /show details of restaurant named\s+(.+?)\s+in\s+(.+)/i, + /show details of\s+(.+?)\s+in\s+(.+)/i, + /details for restaurant named\s+(.+?)\s+in\s+(.+)/i, + /details for\s+(.+?)\s+in\s+(.+)/i, + /details of restaurant named\s+(.+?)\s+in\s+(.+)/i, + /details of\s+(.+?)\s+in\s+(.+)/i, + ]; + for (const p of patterns) { + const m = txt.match(p); + if (m?.[1] && m?.[2]) { + return { + restaurantName: m[1].trim(), + searchLocation: m[2].trim(), + focus: 'details', + }; + } + } + + // Support detail requests without explicit location. + const noLocationPatterns = [ + /show details for restaurant named\s+(.+)/i, + /show details for\s+(.+)/i, + /show details of restaurant named\s+(.+)/i, + /show details of\s+(.+)/i, + /details for restaurant named\s+(.+)/i, + /details for\s+(.+)/i, + /details of restaurant named\s+(.+)/i, + /details of\s+(.+)/i, + ]; + for (const p of noLocationPatterns) { + const m = txt.match(p); + if (m?.[1]) { + return { + restaurantName: m[1].trim(), + searchLocation: fallbackLocation, + focus: 'details', + }; + } + } + + return null; +} diff --git a/mcp-agents/mcp-booking-main/src/agent/mcpSearchClient.ts b/mcp-agents/mcp-booking-main/src/agent/mcpSearchClient.ts new file mode 100644 index 00000000..7f07bf37 --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/agent/mcpSearchClient.ts @@ -0,0 +1,78 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import type { AgentSearchParams, SearchRecommendationsPayload } from './types.js'; + +function parseSearchPayload(text: string): SearchRecommendationsPayload { + const data = JSON.parse(text) as SearchRecommendationsPayload; + if (!data || !Array.isArray(data.recommendations)) { + throw new Error('Unexpected MCP response: missing recommendations array'); + } + return data; +} + +/** + * Single-shot MCP call: connect โ†’ tools/call search_restaurants โ†’ close. + */ +export async function searchRestaurantsViaMcp( + mcpEndpointUrl: string, + params: AgentSearchParams +): Promise { + const client = new Client({ name: 'restaurant-recommendation-agent', version: '1.0.0' }); + const url = new URL(mcpEndpointUrl); + const transport = new StreamableHTTPClientTransport(url); + + try { + await client.connect(transport); + + const result = await client.callTool({ + name: 'search_restaurants', + arguments: { + placeName: params.placeName, + cuisineTypes: params.cuisineTypes, + ...(params.keyword ? { keyword: params.keyword } : {}), + mood: params.mood, + event: params.event, + ...(params.radius !== undefined ? { radius: params.radius } : {}), + ...(params.priceLevel !== undefined ? { priceLevel: params.priceLevel } : {}), + ...(params.locale ? { locale: params.locale } : { locale: 'en' }), + ...(params.strictCuisineFiltering !== undefined + ? { strictCuisineFiltering: params.strictCuisineFiltering } + : {}), + }, + }); + + const blocks = Array.isArray(result.content) ? result.content : []; + + if (result.isError) { + const msg = + blocks.map(c => (c.type === 'text' ? c.text : '')).join(' ') || + 'MCP tool returned an error'; + throw new Error(msg); + } + + const textBlock = blocks.find( + (c): c is { type: 'text'; text: string } => c.type === 'text' + ); + if (!textBlock || textBlock.type !== 'text') { + throw new Error('MCP tool returned no text content'); + } + + const trimmed = textBlock.text.trim(); + if (trimmed.startsWith('No restaurants found')) { + return { + searchCriteria: { + cuisineTypes: params.cuisineTypes, + mood: params.mood, + event: params.event, + placeName: params.placeName, + }, + totalFound: 0, + recommendations: [], + }; + } + + return parseSearchPayload(trimmed); + } finally { + await client.close(); + } +} diff --git a/mcp-agents/mcp-booking-main/src/agent/parseNaturalLanguageQuery.ts b/mcp-agents/mcp-booking-main/src/agent/parseNaturalLanguageQuery.ts new file mode 100644 index 00000000..b0c91a0c --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/agent/parseNaturalLanguageQuery.ts @@ -0,0 +1,150 @@ +import type { ParsedUserQuery } from './types.js'; + +const DEFAULT_PLACE = + process.env.AGENT_DEFAULT_PLACE || 'Taipei, Taiwan'; + +const CUISINE_KEYWORDS: Array<{ match: RegExp; label: string }> = [ + { match: /\bitalian\b/i, label: 'Italian' }, + { match: /\bjapanese\b/i, label: 'Japanese' }, + { match: /\bsushi\b/i, label: 'Japanese' }, + { match: /\bramen\b/i, label: 'Japanese' }, + { match: /\bmexican\b/i, label: 'Mexican' }, + { match: /\bchinese\b/i, label: 'Chinese' }, + { match: /\bdim sum\b/i, label: 'Chinese' }, + { match: /\bindian\b/i, label: 'Indian' }, + { match: /\bfrench\b/i, label: 'French' }, + { match: /\bthai\b/i, label: 'Thai' }, + { match: /\bkorean\b/i, label: 'Korean' }, + { match: /\bmediterranean\b/i, label: 'Mediterranean' }, + { match: /\bamerican\b/i, label: 'American' }, + { match: /\bsteakhouse\b|\bsteak\b/i, label: 'Steakhouse' }, + { match: /\bseafood\b/i, label: 'Seafood' }, + { match: /\bbbq\b|\bbarbecue\b/i, label: 'Barbecue' }, + { match: /\bvietnamese\b/i, label: 'Vietnamese' }, + { match: /\bgreek\b/i, label: 'Greek' }, + { match: /\bspanish\b|\btapas\b/i, label: 'Spanish' }, + { match: /\bpizza\b/i, label: 'Italian' }, +]; + +const MOOD_RULES: Array<{ match: RegExp; mood: string }> = [ + { match: /\bromantic\b|\bdate night\b|\bcandlelit\b/i, mood: 'romantic' }, + { match: /\bquiet\b|\bintimate\b|\blow[- ]key\b/i, mood: 'quiet' }, + { match: /\bupscale\b|\bfine dining\b|\bluxury\b|\bbougie\b/i, mood: 'upscale' }, + { match: /\blively\b|\bfun\b|\benergetic\b/i, mood: 'fun' }, + { match: /\bcasual\b|\brelaxed\b/i, mood: 'casual' }, +]; + +const EVENT_RULES: Array<{ match: RegExp; event: string }> = [ + { match: /\bbusiness\b|\blunch meeting\b|\bclient\b|\bwork\b/i, event: 'business' }, + { match: /\bbirthday\b|\banniversary\b|\bcelebration\b|\bparty\b/i, event: 'celebration' }, + { match: /\bfamily\b|\bkids\b|\bchildren\b/i, event: 'family' }, + { match: /\bdate\b|\bromantic\b|\banniversary dinner\b/i, event: 'dating' }, + { match: /\bgathering\b|\bfriends\b|\bgroup\b/i, event: 'gathering' }, + { match: /\bdinner\b|\blunch\b|\bbrunch\b|\bmeal\b|\beat\b/i, event: 'casual' }, +]; + +const PRICE_RULES: Array<{ match: RegExp; level: 1 | 2 | 3 | 4 }> = [ + { match: /\bcheap\b|\baffordable\b|\bbudget\b/i, level: 1 }, + { match: /\bmoderate\b|\bmid[- ]range\b/i, level: 2 }, + { match: /\bpricey\b|\bsplurge\b/i, level: 3 }, + { match: /\bexpensive\b|\bfine dining\b|\bluxury\b/i, level: 4 }, +]; + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Strip location and cuisine tokens to derive a dish keyword for Google text search (optional). + * The resolved place name must not become the keyword (avoids e.g. "San Jose in San Jose"). + */ +function extractKeyword( + q: string, + _cuisines: string[], + placeName: string +): string | undefined { + let s = q; + const place = placeName.trim(); + if (place) { + s = s.replace( + new RegExp(`\\b(?:in|near|around)\\s+${escapeRegExp(place)}\\b`, 'i'), + ' ' + ); + s = s.replace(new RegExp(`\\b${escapeRegExp(place)}\\b`, 'gi'), ' '); + } + for (const { match } of CUISINE_KEYWORDS) { + s = s.replace(match, ' '); + } + s = s.replace(/\b(?:in|near|around|for|a|an|the|dinner|lunch|brunch|breakfast|restaurant|place|spot)\b/gi, ' '); + s = s.replace(/\b(?:romantic|casual|quiet|fun|upscale|business|family|celebration|date|gathering)\b/gi, ' '); + s = s.trim().replace(/\s+/g, ' '); + if (!s || s.length < 2) return undefined; + if (place && s.toLowerCase() === place.toLowerCase()) return undefined; + return s.length > 48 ? s.slice(0, 48).trim() : s; +} + +function extractPlaceName(q: string): string | undefined { + const m = q.match( + /\b(?:in|near|around)\s+([A-Za-z][A-Za-z\s,'.-]{1,80}?)(?:\s*$|\s+(?:for|with|tonight|today)?\s*$)/i + ); + if (m?.[1]) { + return m[1].replace(/\s+$/, '').trim(); + } + return undefined; +} + +function inferMood(q: string): string { + for (const { match, mood } of MOOD_RULES) { + if (match.test(q)) return mood; + } + return 'casual'; +} + +function inferEvent(q: string): string { + for (const { match, event } of EVENT_RULES) { + if (match.test(q)) return event; + } + return 'casual'; +} + +function inferPriceLevel(q: string): 1 | 2 | 3 | 4 | undefined { + for (const { match, level } of PRICE_RULES) { + if (match.test(q)) return level; + } + return undefined; +} + +/** + * Rule-based NL โ†’ structured params. No network calls; stateless. + */ +export function parseNaturalLanguageQuery(userText: string): ParsedUserQuery { + const rawQuery = userText.trim(); + const q = rawQuery; + const lower = q.toLowerCase(); + + const cuisineTypes: string[] = []; + const seen = new Set(); + for (const { match, label } of CUISINE_KEYWORDS) { + if (match.test(lower) && !seen.has(label)) { + seen.add(label); + cuisineTypes.push(label); + } + } + + const placeName = extractPlaceName(q) || DEFAULT_PLACE; + const mood = inferMood(lower); + const event = inferEvent(lower); + const priceLevel = inferPriceLevel(lower); + const keyword = extractKeyword(q, cuisineTypes, placeName); + + return { + rawQuery, + placeName, + cuisineTypes, + ...(keyword ? { keyword } : {}), + mood, + event, + ...(priceLevel !== undefined ? { priceLevel } : {}), + locale: process.env.AGENT_LOCALE || 'en', + }; +} diff --git a/mcp-agents/mcp-booking-main/src/agent/photoDisplayUrl.ts b/mcp-agents/mcp-booking-main/src/agent/photoDisplayUrl.ts new file mode 100644 index 00000000..a7cbbb15 --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/agent/photoDisplayUrl.ts @@ -0,0 +1,17 @@ +/** + * Turn Places API (New) photo resource names into browser-openable URLs. + * Used for markdown image embeds in chat. + */ +export function resolvePhotoDisplayUrl(photoRef: string): string | null { + const raw = (photoRef || '').trim(); + if (!raw) return null; + if (raw.startsWith('http://') || raw.startsWith('https://')) { + return raw; + } + const key = process.env.GOOGLE_MAPS_API_KEY?.trim(); + if (!key) return null; + if (raw.startsWith('places/')) { + return `https://places.googleapis.com/v1/${raw}/media?maxWidthPx=1200&key=${encodeURIComponent(key)}`; + } + return null; +} diff --git a/mcp-agents/mcp-booking-main/src/agent/recommendationAgent.ts b/mcp-agents/mcp-booking-main/src/agent/recommendationAgent.ts new file mode 100644 index 00000000..ddb27f6f --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/agent/recommendationAgent.ts @@ -0,0 +1,71 @@ +import { formatRecommendationPicks } from './formatRecommendationPicks.js'; +import { + formatRestaurantDetails, + parseRestaurantDetailsIntent, + type RestaurantDetailsIntent, +} from './formatRestaurantDetails.js'; +import { searchRestaurantsViaMcp } from './mcpSearchClient.js'; +import { parseNaturalLanguageQuery } from './parseNaturalLanguageQuery.js'; +import type { RecommendationAgentOptions } from './types.js'; + +const DEFAULT_MCP_URL = + process.env.MCP_SERVER_URL || 'http://127.0.0.1:3000/mcp'; + +/** + * End-to-end: natural language โ†’ MCP search_restaurants โ†’ formatted picks. + */ +export async function recommendFromNaturalLanguage( + userQuery: string, + options?: Partial +): Promise { + const parsed = parseNaturalLanguageQuery(userQuery); + const mcpUrl = options?.mcpUrl ?? DEFAULT_MCP_URL; + const topN = options?.topN ?? 3; + const locale = options?.locale ?? parsed.locale ?? 'en'; + + const payload = await searchRestaurantsViaMcp(mcpUrl, { + ...parsed, + locale, + }); + + return formatRecommendationPicks(payload, topN); +} + +export async function respondToUserQuery( + userQuery: string, + options?: Partial +): Promise { + const detailsIntent = parseRestaurantDetailsIntent(userQuery); + if (!detailsIntent) { + return recommendFromNaturalLanguage(userQuery, options); + } + + const mcpUrl = options?.mcpUrl ?? DEFAULT_MCP_URL; + const locale = options?.locale ?? 'en'; + const payload = await searchRestaurantsViaMcp(mcpUrl, { + placeName: detailsIntent.searchLocation, + keyword: detailsIntent.restaurantName, + cuisineTypes: [], + mood: 'casual', + event: 'casual', + locale, + strictCuisineFiltering: false, + }); + + return formatRestaurantDetails( + payload, + detailsIntent.restaurantName, + detailsIntent.searchLocation, + detailsIntent.focus ?? 'details' + ); +} + +export type { RestaurantDetailsIntent }; + +export { + parseNaturalLanguageQuery, + formatRecommendationPicks, + searchRestaurantsViaMcp, + formatRestaurantDetails, + parseRestaurantDetailsIntent, +}; diff --git a/mcp-agents/mcp-booking-main/src/agent/types.ts b/mcp-agents/mcp-booking-main/src/agent/types.ts new file mode 100644 index 00000000..3dc98840 --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/agent/types.ts @@ -0,0 +1,66 @@ +import type { RestaurantSearchParams } from '../types/index.js'; + +export type PriceLevelLabel = 1 | 2 | 3 | 4; + +/** Structured input sent to MCP search_restaurants */ +export interface AgentSearchParams { + placeName: string; + cuisineTypes: string[]; + keyword?: string; + mood: string; + event: string; + radius?: number; + priceLevel?: PriceLevelLabel; + locale?: string; + strictCuisineFiltering?: boolean; +} + +/** Parsed NL query ready for search */ +export interface ParsedUserQuery extends AgentSearchParams { + rawQuery: string; +} + +export interface SearchRecommendationsPayload { + searchCriteria: RestaurantSearchParams; + totalFound: number; + recommendations: Array<{ + restaurant: { + placeId: string; + name: string; + address: string; + rating: number; + userRatingsTotal: number; + priceLevel?: number; + cuisineTypes: string[]; + googleMapsUrl?: string; + yelpBusinessId?: string; + yelpAlias?: string; + yelpUrl?: string; + yelpMenuUrl?: string; + website?: string; + phoneNumber?: string; + photos?: string[]; + openingHours?: { + openNow: boolean; + weekdayText?: string[]; + }; + reviews?: Array<{ + authorName: string; + rating: number; + text: string; + time: number; + }>; + }; + score: number; + reasoning: string; + suitabilityForEvent: number; + moodMatch: number; + }>; +} + +export type RecommendationAgentOptions = { + mcpUrl: string; + /** Max picks to show (2โ€“3) */ + topN?: number; + locale?: string; +}; diff --git a/mcp-agents/mcp-booking-main/src/fetch_uagent.py b/mcp-agents/mcp-booking-main/src/fetch_uagent.py new file mode 100644 index 00000000..3db81575 --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/fetch_uagent.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +""" +Fetch.ai uAgent wrapper for restaurant recommendations. + +This keeps the existing recommendation logic unchanged by invoking: + npx tsx src/agent/cli.ts "" + +Run: + python src/fetch_uagent.py + +Required: + - MCP server running separately (npm run dev) + - Node deps installed (npm install) + - Python deps: pip install -r requirements-fetch-uagent.txt +""" + +from __future__ import annotations + +import asyncio +import os +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict +from uuid import uuid4 + +from uagents import Agent, Context, Model, Protocol +from uagents_core.contrib.protocols.chat import ( + ChatAcknowledgement, + ChatMessage, + TextContent, + chat_protocol_spec, +) +from uagents_core.contrib.protocols.payment import ( + CommitPayment, + CompletePayment, + Funds, + RejectPayment, + RequestPayment, + payment_protocol_spec, +) + +from stripe_checkout import ( + create_embedded_checkout_session, + create_hosted_checkout_session, + get_amount_cents, + is_configured as stripe_is_configured, + verify_checkout_session_paid, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] + +DEFAULT_PORT = int(os.getenv("FETCH_UAGENT_PORT", "8000")) +DEFAULT_NAME = os.getenv("FETCH_UAGENT_NAME", "restaurant_recommendation_uagent") +DEFAULT_SEED = os.getenv( + "FETCH_UAGENT_SEED", + "replace-this-with-your-own-strong-seed-phrase-please", +) +DEFAULT_ENDPOINT = os.getenv("FETCH_UAGENT_ENDPOINT", "").strip() +AGENTVERSE_API_KEY = os.getenv("AGENTVERSE_API_KEY", "").strip() +MAILBOX_ENABLED = os.getenv("FETCH_UAGENT_MAILBOX", "true").lower() in { + "1", + "true", + "yes", +} + +FREE_REQUEST_LIMIT = int(os.getenv("FREE_REQUEST_LIMIT", "3")) + + +# Agentverse chat can use a different `sender` per message; per-sender counters then never +# reach the limit. Use "global" (default) for one shared quota, or "sender" for strict per-peer. +def _paywall_state_key(sender: str) -> str: + mode = (os.getenv("PAYWALL_SCOPE", "global") or "global").lower().strip() + if mode in ("sender", "per_sender", "peer"): + return sender + return "__global__" + + +class RequestMessage(Model): + text: str + + +class ResponseMessage(Model): + text: str + + +class RecommendResult: + def __init__(self, ok: bool, text: str): + self.ok = ok + self.text = text + + +@dataclass +class UserGateState: + """Per-sender usage and payment state (in-memory; restarts reset).""" + + requests_used: int = 0 + paid_unlocked: bool = False + pending_query: str = "" + pending_checkout_session_id: str = "" + + +_gate_state_by_sender: Dict[str, UserGateState] = {} + + +def _gate_state(sender: str) -> UserGateState: + key = _paywall_state_key(sender) + state = _gate_state_by_sender.get(key) + if state is None: + state = UserGateState() + _gate_state_by_sender[key] = state + return state + + +def _paywall_chat_text() -> str: + amount_cents = get_amount_cents() + amount_usd = f"{amount_cents / 100:.2f}" + return ( + f"You've used your {FREE_REQUEST_LIMIT} free requests. " + f"Please pay ${amount_usd} via Stripe to continue." + ) + + +def build_stripe_checkout_for_gate(sender: str, state: UserGateState) -> dict | None: + """ + Create a Stripe Checkout payload for RequestPayment.metadata[\"stripe\"]. + + Prefer hosted checkout so chat can include a real pay link; fall back to embedded. + """ + if not stripe_is_configured(): + return None + corr = state.pending_checkout_session_id or str(uuid4()) + checkout = create_hosted_checkout_session( + user_address=sender, + chat_session_id=corr, + description="Unlock unlimited restaurant discovery queries.", + ) + if not checkout: + checkout = create_embedded_checkout_session( + user_address=sender, + chat_session_id=corr, + description="Unlock unlimited restaurant discovery queries.", + ) + if not checkout: + return None + state.pending_checkout_session_id = checkout.get("checkout_session_id", "") + return checkout + + +def _paywall_user_message(checkout: dict | None) -> str: + msg = _paywall_chat_text() + if checkout and checkout.get("checkout_url"): + msg += f"\n\n[Pay with Stripe]({checkout['checkout_url']})" + elif checkout and checkout.get("client_secret"): + msg += ( + "\n\nIf you do not see a Pay card above, reload the chat or open the " + "agent in Agentverse โ€” payment uses the embedded Stripe checkout." + ) + return msg + + +async def _emit_request_payment(ctx: Context, sender: str, checkout: dict) -> None: + amount_cents = get_amount_cents() + amount_usd = f"{amount_cents / 100:.2f}" + req = RequestPayment( + accepted_funds=[ + Funds(currency="USD", amount=amount_usd, payment_method="stripe") + ], + recipient=str(ctx.agent.address), + description=f"Pay ${amount_usd} to continue using restaurant discovery.", + metadata={"stripe": checkout, "service": "restaurant_discovery"}, + ) + await ctx.send(sender, req) + + +def _clean_cli_output(raw: str) -> str: + lines = [line.rstrip() for line in raw.splitlines()] + filtered: list[str] = [] + for line in lines: + if line.startswith("[dotenv@"): + continue + if line.startswith("> mcp-restaurant-booking@"): + continue + if line.startswith("> tsx "): + continue + filtered.append(line) + while filtered and filtered[0] == "": + filtered.pop(0) + while filtered and filtered[-1] == "": + filtered.pop() + return "\n".join(filtered).strip() + + +def _run_recommender_cli(query: str) -> RecommendResult: + cmd = ["npx", "tsx", "src/agent/cli.ts", query] + proc = subprocess.run( + cmd, + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + env=os.environ.copy(), + check=False, + ) + + if proc.returncode == 0: + out = _clean_cli_output(proc.stdout) + if out: + return RecommendResult(ok=True, text=out) + return RecommendResult(ok=False, text="Empty response from recommendation CLI.") + + err = _clean_cli_output(proc.stderr or proc.stdout) + return RecommendResult(ok=False, text=err or "Recommendation CLI failed.") + + +async def recommend(query: str) -> RecommendResult: + return await asyncio.to_thread(_run_recommender_cli, query) + + +def _chat_text_message(text: str) -> ChatMessage: + return ChatMessage( + timestamp=datetime.now(timezone.utc), + msg_id=uuid4(), + content=[TextContent(type="text", text=text)], + ) + + +def _get_chat_user_text(msg: ChatMessage) -> str: + """Extract user text from ChatMessage.content (TextContent items).""" + parts: list[str] = [] + for item in msg.content or []: + if isinstance(item, TextContent): + parts.append((item.text or "").strip()) + elif hasattr(item, "text"): + t = getattr(item, "text", None) + if t is not None: + parts.append(str(t).strip()) + return " ".join(p for p in parts if p).strip() + + +agent_kwargs = { + "name": DEFAULT_NAME, + "seed": DEFAULT_SEED, + "port": DEFAULT_PORT, + "mailbox": MAILBOX_ENABLED, +} +if DEFAULT_ENDPOINT: + agent_kwargs["endpoint"] = [DEFAULT_ENDPOINT] +if AGENTVERSE_API_KEY: + agent_kwargs["agentverse"] = {"api_key": AGENTVERSE_API_KEY} + +agent = Agent(**agent_kwargs) +protocol = Protocol(name="RestaurantRecommendationProtocol", version="1.0.0") +chat_protocol = Protocol(spec=chat_protocol_spec) +payment_protocol = Protocol(spec=payment_protocol_spec, role="seller") + + +@agent.on_event("startup") +async def startup(ctx: Context) -> None: + endpoint = f"http://127.0.0.1:{DEFAULT_PORT}" + ctx.logger.info("uAgent started") + ctx.logger.info("Name: %s", DEFAULT_NAME) + ctx.logger.info("Address: %s", agent.address) + ctx.logger.info("Port: %s", DEFAULT_PORT) + ctx.logger.info("Local endpoint: %s", endpoint) + ctx.logger.info("Mailbox enabled: %s", MAILBOX_ENABLED) + if DEFAULT_ENDPOINT: + ctx.logger.info("Advertised endpoint: %s", DEFAULT_ENDPOINT) + ctx.logger.info("Ensure MCP server is running at http://127.0.0.1:3000/mcp") + ctx.logger.info( + "Payment: after %s free requests, Stripe via Agent Payment Protocol (seller).", + FREE_REQUEST_LIMIT, + ) + ctx.logger.info( + "Paywall scope: %s (set PAYWALL_SCOPE=sender for per-peer limits).", + (os.getenv("PAYWALL_SCOPE", "global") or "global").lower(), + ) + + +@protocol.on_message(RequestMessage, replies=ResponseMessage) +async def handle_request(ctx: Context, sender: str, msg: RequestMessage) -> None: + query = (msg.text or "").strip() + if not query: + await ctx.send(sender, ResponseMessage(text="Missing request text.")) + return + + state = _gate_state(sender) + # Hosted Checkout may complete in the browser without CommitPayment; unlock on next message. + if not state.paid_unlocked and state.pending_checkout_session_id: + if verify_checkout_session_paid(state.pending_checkout_session_id): + state.paid_unlocked = True + pending = state.pending_query + state.pending_query = "" + state.pending_checkout_session_id = "" + if pending: + result = await recommend(pending) + if result.ok: + await ctx.send(sender, ResponseMessage(text=result.text)) + else: + await ctx.send( + sender, + ResponseMessage( + text=f"Failed to get recommendations: {result.text}" + ), + ) + return + + if state.paid_unlocked: + result = await recommend(query) + if result.ok: + await ctx.send(sender, ResponseMessage(text=result.text)) + else: + await ctx.send( + sender, + ResponseMessage(text=f"Failed to get recommendations: {result.text}"), + ) + return + + if state.requests_used >= FREE_REQUEST_LIMIT: + state.pending_query = query + checkout = build_stripe_checkout_for_gate(sender, state) + await ctx.send(sender, ResponseMessage(text=_paywall_user_message(checkout))) + if checkout: + await _emit_request_payment(ctx, sender, checkout) + else: + await ctx.send( + sender, + ResponseMessage( + text="Stripe is not configured or checkout failed. " + "Set STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY, then restart the agent." + ), + ) + return + + result = await recommend(query) + if result.ok: + state.requests_used += 1 + await ctx.send(sender, ResponseMessage(text=result.text)) + else: + await ctx.send( + sender, + ResponseMessage(text=f"Failed to get recommendations: {result.text}"), + ) + + +@chat_protocol.on_message(ChatAcknowledgement) +async def handle_chat_ack(ctx: Context, sender: str, msg: ChatAcknowledgement) -> None: + ctx.logger.info( + "Got chat acknowledgement from %s for msg_id=%s", + sender, + msg.acknowledged_msg_id, + ) + + +@chat_protocol.on_message(ChatMessage) +async def handle_chat_message(ctx: Context, sender: str, msg: ChatMessage) -> None: + if msg.msg_id: + await ctx.send( + sender, + ChatAcknowledgement( + timestamp=datetime.now(timezone.utc), + acknowledged_msg_id=msg.msg_id, + ), + ) + + query = _get_chat_user_text(msg) + if not query: + await ctx.send(sender, _chat_text_message("Please send a text query.")) + return + + state = _gate_state(sender) + # Hosted Checkout may complete in the browser without CommitPayment; unlock on next message. + if not state.paid_unlocked and state.pending_checkout_session_id: + if verify_checkout_session_paid(state.pending_checkout_session_id): + state.paid_unlocked = True + pending = state.pending_query + state.pending_query = "" + state.pending_checkout_session_id = "" + if pending: + result = await recommend(pending) + response_text = ( + result.text + if result.ok + else f"Failed to get recommendations: {result.text}" + ) + await ctx.send(sender, _chat_text_message(response_text)) + return + + if state.paid_unlocked: + result = await recommend(query) + response_text = ( + result.text + if result.ok + else f"Failed to get recommendations: {result.text}" + ) + await ctx.send(sender, _chat_text_message(response_text)) + return + + if state.requests_used >= FREE_REQUEST_LIMIT: + state.pending_query = query + checkout = build_stripe_checkout_for_gate(sender, state) + await ctx.send(sender, _chat_text_message(_paywall_user_message(checkout))) + if checkout: + await _emit_request_payment(ctx, sender, checkout) + else: + await ctx.send( + sender, + _chat_text_message( + "Stripe is not configured or checkout failed. " + "Set STRIPE_SECRET_KEY and STRIPE_PUBLISHABLE_KEY, then restart the agent." + ), + ) + return + + result = await recommend(query) + if result.ok: + state.requests_used += 1 + response_text = ( + result.text if result.ok else f"Failed to get recommendations: {result.text}" + ) + await ctx.send(sender, _chat_text_message(response_text)) + + +@payment_protocol.on_message(CommitPayment) +async def handle_commit_payment(ctx: Context, sender: str, msg: CommitPayment) -> None: + state = _gate_state(sender) + if msg.funds.payment_method != "stripe" or not msg.transaction_id: + await ctx.send( + sender, + RejectPayment( + reason="Unsupported payment method (expected stripe checkout session id)." + ), + ) + return + + if not verify_checkout_session_paid(msg.transaction_id): + await ctx.send( + sender, + RejectPayment( + reason="Stripe payment not completed yet. Please finish checkout." + ), + ) + return + + state.paid_unlocked = True + state.pending_checkout_session_id = msg.transaction_id + await ctx.send(sender, CompletePayment(transaction_id=msg.transaction_id)) + + pending = state.pending_query + state.pending_query = "" + if pending: + result = await recommend(pending) + response_text = ( + result.text + if result.ok + else f"Failed to get recommendations: {result.text}" + ) + await ctx.send(sender, _chat_text_message(response_text)) + else: + await ctx.send( + sender, + _chat_text_message("Payment confirmed. You're unlocked โ€” ask me anything."), + ) + + +@payment_protocol.on_message(RejectPayment) +async def handle_reject_payment(ctx: Context, sender: str, msg: RejectPayment) -> None: + ctx.logger.info("RejectPayment from %s: %s", sender, msg.reason) + + +if __name__ == "__main__": + agent.include(protocol, publish_manifest=True) + agent.include(chat_protocol, publish_manifest=True) + agent.include(payment_protocol, publish_manifest=True) + agent.run() diff --git a/mcp-agents/mcp-booking-main/src/index.ts b/mcp-agents/mcp-booking-main/src/index.ts new file mode 100644 index 00000000..e4a24026 --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/index.ts @@ -0,0 +1,369 @@ +#!/usr/bin/env node + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express from 'express'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import dotenv from 'dotenv'; + +import { GoogleMapsService } from './services/googleMapsService.js'; +import { RestaurantRecommendationService } from './services/restaurantRecommendationService.js'; +import { YelpService } from './services/yelpService.js'; +import { RestaurantSearchParams } from './types/index.js'; + +// Load environment variables +dotenv.config(); + +// Default coordinates for Taiwan +const DEFAULT_LATITUDE = parseFloat( + process.env.DEFAULT_LATITUDE || '24.1501164' +); +const DEFAULT_LONGITUDE = parseFloat( + process.env.DEFAULT_LONGITUDE || '120.6692299' +); +const DEFAULT_SEARCH_RADIUS = parseInt( + process.env.DEFAULT_SEARCH_RADIUS || '3000' +); // 3km in meters +const PORT = parseInt(process.env.PORT || '3000'); + +class RestaurantBookingServer { + private googleMapsService: GoogleMapsService; + private recommendationService: RestaurantRecommendationService; + private yelpService: YelpService; + + constructor() { + // Initialize services + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + if (!apiKey) { + throw new Error('GOOGLE_MAPS_API_KEY environment variable is required'); + } + + this.googleMapsService = new GoogleMapsService(apiKey); + this.recommendationService = new RestaurantRecommendationService(); + this.yelpService = new YelpService(process.env.YELP_API_KEY); + if (this.yelpService.isEnabled()) { + console.info('[MCP] Yelp Fusion: enabled (Yelp menu/profile URLs added when a match is found)'); + } else { + console.info( + '[MCP] Yelp Fusion: disabled โ€” set YELP_API_KEY in .env and restart this server for Yelp menu links' + ); + } + } + + private createServer(): McpServer { + const server = new McpServer({ + name: 'restaurant-booking-server', + version: '1.0.0', + }); + + this.setupTools(server); + return server; + } + + private setupTools(server: McpServer) { + // Search restaurants tool + server.registerTool( + 'search_restaurants', + { + title: 'Search for restaurants', + description: + 'Search for restaurants based on location, cuisine, keyword, mood, event, radius, price level, and locale', + inputSchema: { + latitude: z + .number() + .optional() + .describe( + `Latitude of the search location (default: ${DEFAULT_LATITUDE} - Taiwan)` + ), + longitude: z + .number() + .optional() + .describe( + `Longitude of the search location (default: ${DEFAULT_LONGITUDE} - Taiwan)` + ), + placeName: z + .string() + .optional() + .describe( + 'Place name to search near (e.g., "New York", "Tokyo", "London"). Alternative to providing latitude/longitude coordinates.' + ), + cuisineTypes: z + .array(z.string()) + .optional() + .describe( + 'Array of preferred cuisine types (e.g., ["Italian", "Japanese", "Mexican"])' + ), + keyword: z + .string() + .optional() + .describe( + 'Search for specific food types or dishes (e.g., "hotpot", "sushi", "pizza", "ramen", "dim sum", "barbecue")' + ), + mood: z + .string() + .describe( + 'Desired mood/atmosphere (e.g., "romantic", "casual", "upscale", "fun", "quiet")' + ), + event: z + .string() + .describe( + "Type of event or occasion (e.g., 'dating', 'gathering', 'business', 'casual', 'celebration')" + ), + radius: z + .number() + .optional() + .describe( + `Search radius in meters (default: ${DEFAULT_SEARCH_RADIUS} = ${DEFAULT_SEARCH_RADIUS / 1000}km)` + ), + priceLevel: z + .number() + .min(1) + .max(4) + .optional() + .describe( + 'Price level preference (1=inexpensive, 4=very expensive)' + ), + locale: z + .string() + .optional() + .describe( + 'Locale for search results and Google API responses (e.g., "en" for English, "zh-TW" for Traditional Chinese, "ja" for Japanese, "ko" for Korean, "th" for Thai). Affects restaurant names, reviews, and other text content.' + ), + strictCuisineFiltering: z + .boolean() + .optional() + .describe( + 'If true, only restaurants that match the specified cuisine types will be returned. If false (default), all restaurants will be returned but cuisine matches will be scored higher.' + ), + }, + }, + async args => { + return await this.handleSearchRestaurants(args); + } + ); + } + + private async handleSearchRestaurants(args: any) { + const searchParams: RestaurantSearchParams = { + // Only include location if placeName is not provided + ...(args.placeName + ? { placeName: args.placeName } + : { + location: { + latitude: args.latitude || DEFAULT_LATITUDE, + longitude: args.longitude || DEFAULT_LONGITUDE, + }, + }), + cuisineTypes: args.cuisineTypes || [], + keyword: args.keyword, + mood: args.mood, + event: args.event, + radius: args.radius || DEFAULT_SEARCH_RADIUS, + priceLevel: args.priceLevel, + locale: args.locale || 'en', + strictCuisineFiltering: args.strictCuisineFiltering || false, + }; + + // Search for restaurants + const restaurants = + await this.googleMapsService.searchRestaurants(searchParams); + + if (restaurants.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: 'No restaurants found matching your criteria. Try expanding your search radius or adjusting your preferences.', + }, + ], + }; + } + + // Get AI recommendations + const recommendations = await this.recommendationService.getRecommendations( + restaurants, + searchParams + ); + + const enriched = await Promise.all( + recommendations.map(async rec => { + const yelp = this.yelpService.isEnabled() + ? await this.yelpService.enrichRestaurant(rec.restaurant) + : {}; + return { rec, yelp }; + }) + ); + + const result = { + searchCriteria: searchParams, + totalFound: restaurants.length, + recommendations: enriched.map(({ rec, yelp }) => ({ + restaurant: { + placeId: rec.restaurant.placeId, + name: rec.restaurant.name, + address: rec.restaurant.address, + rating: rec.restaurant.rating, + userRatingsTotal: rec.restaurant.userRatingsTotal, + priceLevel: rec.restaurant.priceLevel, + cuisineTypes: rec.restaurant.cuisineTypes, + photos: rec.restaurant.photos, + phoneNumber: rec.restaurant.phoneNumber, + website: rec.restaurant.website, + googleMapsUrl: rec.restaurant.googleMapsUrl, + openingHours: rec.restaurant.openingHours, + reviews: rec.restaurant.reviews, + distance: rec.restaurant.distance, + bookingInfo: rec.restaurant.bookingInfo, + reservable: rec.restaurant.reservable, + curbsidePickup: rec.restaurant.curbsidePickup, + delivery: rec.restaurant.delivery, + dineIn: rec.restaurant.dineIn, + takeout: rec.restaurant.takeout, + servesBreakfast: rec.restaurant.servesBreakfast, + servesLunch: rec.restaurant.servesLunch, + servesDinner: rec.restaurant.servesDinner, + servesBrunch: rec.restaurant.servesBrunch, + servesBeer: rec.restaurant.servesBeer, + servesWine: rec.restaurant.servesWine, + servesVegetarianFood: rec.restaurant.servesVegetarianFood, + ...yelp, + }, + score: Math.round(rec.score * 10) / 10, + reasoning: rec.reasoning, + suitabilityForEvent: rec.suitabilityForEvent, + moodMatch: rec.moodMatch, + })), + }; + + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + async run() { + const app = express(); + app.use(express.json()); + + // Map to store transports by session ID for stateful connections + const transports: { [sessionId: string]: StreamableHTTPServerTransport } = + {}; + + // Health check endpoint + app.get('/health', (req, res) => { + res.json({ status: 'ok', service: 'restaurant-booking-mcp-server' }); + }); + + // Handle POST requests for client-to-server communication + app.post('/mcp', async (req, res) => { + try { + // Check for existing session ID + const sessionId = req.headers['mcp-session-id'] as string | undefined; + let transport: StreamableHTTPServerTransport; + let server: McpServer; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + // New initialization request + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: sessionId => { + // Store the transport by session ID + transports[sessionId] = transport; + }, + }); + + // Clean up transport when closed + transport.onclose = () => { + if (transport.sessionId) { + delete transports[transport.sessionId]; + } + }; + + // Create new server instance + server = this.createServer(); + + // Connect to the MCP server + await server.connect(transport); + } else { + // Invalid request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; + } + + // Handle the request + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); + } + } + }); + + // Reusable handler for GET and DELETE requests + const handleSessionRequest = async ( + req: express.Request, + res: express.Response + ) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + }; + + // Handle GET requests for server-to-client notifications via SSE + app.get('/mcp', handleSessionRequest); + + // Handle DELETE requests for session termination + app.delete('/mcp', handleSessionRequest); + + // Start the server + app.listen(PORT, '0.0.0.0', () => { + console.log( + `Restaurant Booking MCP Server running on http://0.0.0.0:${PORT}` + ); + console.log(`Health check available at http://0.0.0.0:${PORT}/health`); + console.log(`MCP endpoint available at http://0.0.0.0:${PORT}/mcp`); + }); + } +} + +// Start the server +const server = new RestaurantBookingServer(); +server.run().catch(error => { + console.error('Failed to start server:', error); + process.exit(1); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + process.exit(0); +}); diff --git a/mcp-agents/mcp-booking-main/src/services/googleMapsService.ts b/mcp-agents/mcp-booking-main/src/services/googleMapsService.ts new file mode 100644 index 00000000..1df122da --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/services/googleMapsService.ts @@ -0,0 +1,622 @@ +import { PlacesClient } from '@googlemaps/places'; +import { + Location, + Restaurant, + RestaurantSearchParams, +} from '../types/index.js'; + +export class GoogleMapsService { + private client: PlacesClient; + + constructor(apiKey: string) { + this.client = new PlacesClient({ + apiKey: apiKey, + }); + } + + /** + * Calculate the distance between two points using the Haversine formula + * @param lat1 Latitude of first point + * @param lon1 Longitude of first point + * @param lat2 Latitude of second point + * @param lon2 Longitude of second point + * @returns Distance in meters + */ + private calculateDistance( + lat1: number, + lon1: number, + lat2: number, + lon2: number + ): number { + const R = 6371000; // Earth's radius in meters + const dLat = this.toRadians(lat2 - lat1); + const dLon = this.toRadians(lon2 - lon1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRadians(lat1)) * + Math.cos(this.toRadians(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; // Distance in meters + } + + /** + * Convert degrees to radians + */ + private toRadians(degrees: number): number { + return degrees * (Math.PI / 180); + } + + /** + * Search for restaurants using Text Search API + * Supports direct text queries like "good restaurant in Paris" or "sushi near Tokyo" + */ + async searchRestaurants( + params: RestaurantSearchParams + ): Promise { + try { + const { + location, + placeName, + cuisineTypes, + keyword, + radius = 2000, + locale = 'en', + } = params; + + // Build search query for Text Search API + let textQuery = ''; + + const kwTrimmed = keyword?.trim(); + const placeTrimmed = placeName?.trim() ?? ''; + const keywordDuplicatesPlace = + Boolean( + kwTrimmed && + placeTrimmed && + kwTrimmed.toLowerCase() === placeTrimmed.toLowerCase() + ); + const effectiveKeyword = + kwTrimmed && !keywordDuplicatesPlace ? kwTrimmed : undefined; + + // If keyword is provided, prioritize it + if (effectiveKeyword) { + textQuery = effectiveKeyword; + // Add location context + if (placeName) { + textQuery += ` in ${placeName}`; + } + // If cuisine types are also provided, combine them + if (cuisineTypes && cuisineTypes.length > 0) { + textQuery += ` ${cuisineTypes.join(' OR ')}`; + } + } else { + // Build query from cuisine types and location + const cuisineQuery = + cuisineTypes && cuisineTypes.length > 0 + ? cuisineTypes.join(' OR ') + ' restaurant' + : 'good restaurant'; + + if (placeName) { + textQuery = `${cuisineQuery} in ${placeName}`; + } else { + textQuery = cuisineQuery; + } + } + + // Prepare field mask based on required fields + const fieldMask = this.getSearchFieldMask(); + + // Build request for Text Search API + let request; + let response; + + // Use searchNearby only when we have coordinates AND no specific cuisine is requested + if ( + location && + (!cuisineTypes || cuisineTypes.length === 0) && + !placeName + ) { + request = { + includedTypes: ['restaurant'], + locationRestriction: { + circle: { + center: { + latitude: location.latitude, + longitude: location.longitude, + }, + radius: radius, + }, + }, + maxResultCount: 20, + languageCode: locale, + }; + console.info('request', request); + response = await this.client.searchNearby(request, { + otherArgs: { + headers: { + 'X-Goog-FieldMask': fieldMask, + }, + }, + }); + } else { + // Use searchText for: + // 1. Place name searches (with or without cuisine) + // 2. Coordinate searches with specific cuisine + // 3. Keyword searches + if (location && !placeName) { + // Coordinate-based search with cuisine - add location bias + request = { + textQuery, + locationBias: { + circle: { + center: { + latitude: location.latitude, + longitude: location.longitude, + }, + radius: radius, + }, + }, + maxResultCount: 20, + languageCode: locale, + }; + } else { + // Place name search or no location provided + request = { + textQuery, + maxResultCount: 20, + languageCode: locale, + }; + } + console.info('request', request); + response = await this.client.searchText(request, { + otherArgs: { + headers: { + 'X-Goog-FieldMask': fieldMask, + }, + }, + }); + } + + if (!response?.[0]?.places) { + return []; + } + + const places = response[0].places; + + // Convert API response to Restaurant objects + const restaurants: Restaurant[] = []; + + for (const place of places) { + try { + const restaurant = this.convertPlaceToRestaurant(place, location); + if (restaurant) { + restaurants.push(restaurant); + } + } catch (error) { + console.error('Error converting place to restaurant:', error); + } + } + + // Sort restaurants by distance if location provided + if (location) { + restaurants.sort((a, b) => { + const distanceA = a.distance || 0; + const distanceB = b.distance || 0; + return distanceA - distanceB; + }); + } + + return restaurants; + } catch (error) { + console.error('Error searching restaurants:', error); + throw new Error( + `Failed to search restaurants: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } + } + + /** + * Get field mask for search requests based on SKU tiers + */ + private getSearchFieldMask(): string { + // Using Pro SKU fields for comprehensive restaurant data + const proFields = [ + 'places.id', + 'places.displayName', + 'places.formattedAddress', + 'places.location', + 'places.types', + 'places.primaryType', + 'places.businessStatus', + 'places.googleMapsUri', + 'places.photos', + ]; + + // Adding Enterprise SKU fields for restaurant-specific data + const enterpriseFields = [ + 'places.rating', + 'places.userRatingCount', + 'places.priceLevel', + 'places.currentOpeningHours', + 'places.internationalPhoneNumber', + 'places.websiteUri', + ]; + + // Adding Enterprise + Atmosphere SKU fields for booking and service info + const atmosphereFields = [ + 'places.reservable', + 'places.curbsidePickup', + 'places.delivery', + 'places.dineIn', + 'places.takeout', + 'places.servesBreakfast', + 'places.servesLunch', + 'places.servesDinner', + 'places.servesBrunch', + 'places.servesBeer', + 'places.servesWine', + 'places.servesVegetarianFood', + 'places.reviews', + ]; + + return [...proFields, ...enterpriseFields, ...atmosphereFields].join(','); + } + + /** + * Convert API place response to Restaurant object + */ + private convertPlaceToRestaurant( + place: any, + searchLocation?: Location + ): Restaurant | null { + try { + // Validate required fields + if ( + !place.id || + !place.displayName || + !place.formattedAddress || + !place.location + ) { + return null; + } + + // Calculate distance if search location provided + let distance: number | undefined; + if (searchLocation && place.location) { + distance = Math.round( + this.calculateDistance( + searchLocation.latitude, + searchLocation.longitude, + place.location.latitude, + place.location.longitude + ) + ); + } + + // Analyze booking information from website + const bookingInfo = this.analyzeBookingInfo( + place.websiteUri, + place.internationalPhoneNumber + ); + + // Generate Google Maps URL + const googleMapsUrl = + place.googleMapsUri || this.generateGoogleMapsUrl(place.id); + + // Include photo references (up to 3) for downstream detail rendering. + const photos = + place.photos?.slice(0, 3).map((photo: any) => { + if (photo?.name) return photo.name as string; + if (photo?.photoReference) return photo.photoReference as string; + return ''; + }).filter((p: string) => p.length > 0) || []; + + // Convert reviews if present + const reviews = + place.reviews?.map((review: any) => ({ + authorName: review.authorAttribution?.displayName || 'Anonymous', + rating: review.rating || 0, + text: review.text?.text || '', + time: review.publishTime ? Date.parse(review.publishTime) : 0, + })) || []; + + return { + placeId: place.id, + name: place.displayName?.text || place.displayName, + address: place.formattedAddress, + location: { + latitude: place.location.latitude, + longitude: place.location.longitude, + }, + rating: place.rating || 0, + userRatingsTotal: place.userRatingCount || 0, + priceLevel: this.convertPriceLevelFromEnum(place.priceLevel), + cuisineTypes: this.extractCuisineTypes(place.types || []), + photos, + phoneNumber: place.internationalPhoneNumber, + website: place.websiteUri, + googleMapsUrl, + distance, + bookingInfo, + reservable: place.reservable || false, + curbsidePickup: place.curbsidePickup || false, + delivery: place.delivery || false, + dineIn: place.dineIn || false, + takeout: place.takeout || false, + servesBreakfast: place.servesBreakfast || false, + servesLunch: place.servesLunch || false, + servesDinner: place.servesDinner || false, + servesBrunch: place.servesBrunch || false, + servesBeer: place.servesBeer || false, + servesWine: place.servesWine || false, + servesVegetarianFood: place.servesVegetarianFood || false, + openingHours: place.currentOpeningHours + ? { + openNow: place.currentOpeningHours.openNow || false, + weekdayText: place.currentOpeningHours.weekdayDescriptions, + } + : undefined, + reviews: reviews.slice(0, 5), // Limit to 5 reviews + }; + } catch (error) { + console.error('Error converting place to restaurant:', error); + return null; + } + } + + /** + * Convert price level enum back to number + */ + private convertPriceLevelFromEnum(priceLevel?: string): number | undefined { + if (!priceLevel) return undefined; + + const priceLevels: { [key: string]: number } = { + PRICE_LEVEL_INEXPENSIVE: 1, + PRICE_LEVEL_MODERATE: 2, + PRICE_LEVEL_EXPENSIVE: 3, + PRICE_LEVEL_VERY_EXPENSIVE: 4, + }; + return priceLevels[priceLevel]; + } + + /** + * Analyze booking information from website URL and phone number + */ + private analyzeBookingInfo( + website?: string, + phoneNumber?: string + ): { + reservable: boolean; + bookingUrl?: string; + bookingPlatform?: + | 'opentable' + | 'resy' + | 'yelp' + | 'restaurant_website' + | 'google_reserve' + | 'other'; + supportsOnlineBooking: boolean; + requiresPhone: boolean; + } { + const bookingInfo: { + reservable: boolean; + bookingUrl?: string; + bookingPlatform?: + | 'opentable' + | 'resy' + | 'yelp' + | 'restaurant_website' + | 'google_reserve' + | 'other'; + supportsOnlineBooking: boolean; + requiresPhone: boolean; + } = { + reservable: false, + supportsOnlineBooking: false, + requiresPhone: false, + }; + + // Check if phone number is available for reservations + if (phoneNumber) { + bookingInfo.reservable = true; + bookingInfo.requiresPhone = true; + } + + // Analyze website for booking platforms + if (website) { + const url = website.toLowerCase(); + let hostname = ''; + try { + hostname = new URL(website).hostname.toLowerCase(); + } catch (_e) { + // If website is not a valid URL, fallback to substring checks (optional) + hostname = ''; + } + + // OpenTable detection + if (hostname === 'opentable.com' || hostname.endsWith('.opentable.com')) { + return { + ...bookingInfo, + reservable: true, + bookingUrl: website, + bookingPlatform: 'opentable', + supportsOnlineBooking: true, + requiresPhone: false, + }; + } + + // Resy detection + if (hostname === 'resy.com' || hostname.endsWith('.resy.com')) { + return { + ...bookingInfo, + reservable: true, + bookingUrl: website, + bookingPlatform: 'resy', + supportsOnlineBooking: true, + requiresPhone: false, + }; + } + + // Yelp reservations detection + if ( + (hostname === 'yelp.com' || hostname.endsWith('.yelp.com')) && + (url.includes('reservations') || url.includes('book')) + ) { + return { + ...bookingInfo, + reservable: true, + bookingUrl: website, + bookingPlatform: 'yelp', + supportsOnlineBooking: true, + requiresPhone: false, + }; + } + + // Google Reserve detection + if ( + hostname === 'reserve.google.com' || + (hostname === 'google.com' && url.includes('/reserve')) + ) { + return { + ...bookingInfo, + reservable: true, + bookingUrl: website, + bookingPlatform: 'google_reserve', + supportsOnlineBooking: true, + requiresPhone: false, + }; + } + + // Generic restaurant website with potential booking + if (this.hasBookingKeywords(url)) { + return { + ...bookingInfo, + reservable: true, + bookingUrl: website, + bookingPlatform: 'restaurant_website', + supportsOnlineBooking: true, + requiresPhone: false, + }; + } + + // Website exists but no clear booking platform detected + bookingInfo.reservable = true; + bookingInfo.bookingUrl = website; + bookingInfo.bookingPlatform = 'other'; + } + + return bookingInfo; + } + + /** + * Check if URL contains booking-related keywords + */ + private hasBookingKeywords(url: string): boolean { + const bookingKeywords = [ + 'reservation', + 'reservations', + 'book', + 'booking', + 'table', + 'reserve', + 'dine', + 'dining', + 'order', + 'menu', + ]; + + return bookingKeywords.some(keyword => url.includes(keyword)); + } + + /** + * Generate Google Maps URL for a restaurant + */ + private generateGoogleMapsUrl(placeId: string): string { + // Use place_id for the most accurate Google Maps URL + // This format directly opens the place in Google Maps + return `https://www.google.com/maps/search/?api=1&query=Google&query_place_id=${placeId}`; + } + + /** + * Extract cuisine types from Google Places types array + */ + private extractCuisineTypes(types: string[]): string[] { + const cuisineMap: { [key: string]: string } = { + chinese_restaurant: 'Chinese', + japanese_restaurant: 'Japanese', + korean_restaurant: 'Korean', + thai_restaurant: 'Thai', + vietnamese_restaurant: 'Vietnamese', + indian_restaurant: 'Indian', + italian_restaurant: 'Italian', + french_restaurant: 'French', + mexican_restaurant: 'Mexican', + american_restaurant: 'American', + mediterranean_restaurant: 'Mediterranean', + greek_restaurant: 'Greek', + turkish_restaurant: 'Turkish', + spanish_restaurant: 'Spanish', + german_restaurant: 'German', + brazilian_restaurant: 'Brazilian', + seafood_restaurant: 'Seafood', + steakhouse: 'Steakhouse', + pizza_restaurant: 'Pizza', + bakery: 'Bakery', + cafe: 'Cafe', + fast_food_restaurant: 'Fast Food', + fine_dining_restaurant: 'Fine Dining', + buffet_restaurant: 'Buffet', + barbecue_restaurant: 'BBQ', + sushi_restaurant: 'Sushi', + vegetarian_restaurant: 'Vegetarian', + vegan_restaurant: 'Vegan', + }; + + const cuisines: string[] = []; + + for (const type of types) { + // Handle both old format and new format types + const typeKey = + typeof type === 'string' ? type : String(type).toLowerCase(); + if (cuisineMap[typeKey]) { + cuisines.push(cuisineMap[typeKey]); + } + } + + // If no specific cuisine found, add generic restaurant type + if ( + cuisines.length === 0 && + (types.includes('restaurant') || types.includes('establishment')) + ) { + cuisines.push('Restaurant'); + } + + return cuisines; + } + + /** + * Search for restaurants with specific cuisine types + */ + async searchByCuisine( + locationOrPlaceName: Location | string, + cuisineType: string, + radius: number = 7000, + locale: string = 'en' + ): Promise { + const searchParams: RestaurantSearchParams = { + ...(typeof locationOrPlaceName === 'string' + ? { placeName: locationOrPlaceName } + : { location: locationOrPlaceName }), + cuisineTypes: [cuisineType], + mood: '', + event: 'casual dining', + radius, + locale, + }; + + return this.searchRestaurants(searchParams); + } +} diff --git a/mcp-agents/mcp-booking-main/src/services/restaurantRecommendationService.ts b/mcp-agents/mcp-booking-main/src/services/restaurantRecommendationService.ts new file mode 100644 index 00000000..8c90ad4b --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/services/restaurantRecommendationService.ts @@ -0,0 +1,380 @@ +import { + Restaurant, + RestaurantSearchParams, + RestaurantRecommendation, +} from '../types/index.js'; + +export class RestaurantRecommendationService { + /** + * Analyze and score restaurants based on search criteria + * Optimized with parallel processing + */ + async getRecommendations( + restaurants: Restaurant[], + params: RestaurantSearchParams + ): Promise { + // Apply strict cuisine filtering if requested + let filteredRestaurants = restaurants; + if (params.strictCuisineFiltering && params.cuisineTypes.length > 0) { + filteredRestaurants = restaurants.filter(restaurant => + this.calculateCuisineMatch(restaurant, params.cuisineTypes) > 0 + ); + + // If no restaurants match the strict criteria, fall back to all restaurants + // with a warning that no exact matches were found + if (filteredRestaurants.length === 0) { + console.warn('No restaurants found matching strict cuisine criteria, falling back to all results'); + filteredRestaurants = restaurants; + } + } + + // Process all restaurants in parallel for better performance + const recommendationPromises = filteredRestaurants.map(async restaurant => { + const [score, suitabilityForEvent, moodMatch] = await Promise.all([ + // These can run in parallel since they're independent calculations + Promise.resolve(this.calculateRestaurantScore(restaurant, params)), + Promise.resolve( + this.calculateEventSuitability(restaurant, params.event) + ), + Promise.resolve(this.calculateMoodMatch(restaurant, params.mood)), + ]); + + const reasoning = this.generateReasoning( + restaurant, + params, + score, + suitabilityForEvent, + moodMatch + ); + + return { + restaurant, + score, + reasoning, + suitabilityForEvent, + moodMatch, + }; + }); + + const recommendations = await Promise.all(recommendationPromises); + + // Sort by score (highest first) and return top 3 + return recommendations.sort((a, b) => b.score - a.score).slice(0, 3); + } + + /** + * Calculate overall score for a restaurant based on multiple factors + */ + private calculateRestaurantScore( + restaurant: Restaurant, + params: RestaurantSearchParams + ): number { + let score = 0; + let factors = 0; + + // Rating factor (40% weight) + if (restaurant.rating > 0) { + score += (restaurant.rating / 5) * 40; + factors++; + } + + // Review count factor (20% weight) - more reviews = more reliable + if (restaurant.userRatingsTotal > 0) { + const reviewScore = Math.min(restaurant.userRatingsTotal / 100, 1) * 20; + score += reviewScore; + factors++; + } + + // Cuisine match factor (20% weight) + const cuisineMatch = this.calculateCuisineMatch( + restaurant, + params.cuisineTypes + ); + score += cuisineMatch * 20; + factors++; + + // Event suitability factor (10% weight) + const eventSuitability = this.calculateEventSuitability( + restaurant, + params.event + ); + score += (eventSuitability / 10) * 10; + factors++; + + // Mood match factor (10% weight) + const moodMatch = this.calculateMoodMatch(restaurant, params.mood); + score += (moodMatch / 10) * 10; + factors++; + + return factors > 0 ? score : 0; + } + + /** + * Calculate how well restaurant cuisine matches search criteria + */ + private calculateCuisineMatch( + restaurant: Restaurant, + searchCuisines: string[] + ): number { + if (searchCuisines.length === 0) return 1; // No specific cuisine preference + + const restaurantCuisines = restaurant.cuisineTypes.map(c => + c.toLowerCase() + ); + const searchCuisinesLower = searchCuisines.map(c => c.toLowerCase()); + + let matches = 0; + for (const searchCuisine of searchCuisinesLower) { + for (const restaurantCuisine of restaurantCuisines) { + if ( + restaurantCuisine.includes(searchCuisine) || + searchCuisine.includes(restaurantCuisine) + ) { + matches++; + break; + } + } + } + + return matches / searchCuisines.length; + } + + /** + * Calculate suitability for specific events (1-10 scale) + */ + private calculateEventSuitability( + restaurant: Restaurant, + event: string + ): number { + const eventFactors = { + dating: { + preferredPriceLevel: [2, 3, 4], // Mid to high-end + preferredCuisines: [ + 'italian', + 'french', + 'japanese', + 'mediterranean', + 'fine dining', + ], + avoidCuisines: ['fast food', 'buffet'], + minRating: 4.0, + atmosphereKeywords: ['romantic', 'intimate', 'cozy', 'elegant'], + }, + gathering: { + preferredPriceLevel: [1, 2, 3], // Budget to mid-range + preferredCuisines: [ + 'american', + 'italian', + 'chinese', + 'mexican', + 'pizza', + ], + avoidCuisines: ['fine dining'], + minRating: 3.5, + atmosphereKeywords: ['family-friendly', 'spacious', 'casual', 'kids'], + }, + business: { + preferredPriceLevel: [2, 3, 4], // Mid to high-end + preferredCuisines: ['american', 'italian', 'steakhouse', 'seafood'], + avoidCuisines: ['fast food', 'buffet'], + minRating: 4.0, + atmosphereKeywords: ['quiet', 'professional', 'upscale', 'private'], + }, + casual: { + preferredPriceLevel: [1, 2], // Budget to mid-range + preferredCuisines: ['american', 'pizza', 'cafe', 'mexican', 'asian'], + avoidCuisines: [], + minRating: 3.0, + atmosphereKeywords: ['casual', 'relaxed', 'friendly'], + }, + celebration: { + preferredPriceLevel: [3, 4], // High-end + preferredCuisines: [ + 'fine dining', + 'steakhouse', + 'seafood', + 'french', + 'italian', + ], + avoidCuisines: ['fast food', 'cafe'], + minRating: 4.2, + atmosphereKeywords: ['upscale', 'elegant', 'special', 'celebration'], + }, + }; + + const factors = eventFactors[event as keyof typeof eventFactors]; + if (!factors) return 5; // Default score + + let score = 5; // Base score + + // Price level suitability + if ( + restaurant.priceLevel && + factors.preferredPriceLevel.includes(restaurant.priceLevel) + ) { + score += 2; + } + + // Cuisine suitability + const restaurantCuisines = restaurant.cuisineTypes.map(c => + c.toLowerCase() + ); + const hasPreferredCuisine = factors.preferredCuisines.some(cuisine => + restaurantCuisines.some(rc => rc.includes(cuisine)) + ); + const hasAvoidedCuisine = factors.avoidCuisines.some(cuisine => + restaurantCuisines.some(rc => rc.includes(cuisine)) + ); + + if (hasPreferredCuisine) score += 2; + if (hasAvoidedCuisine) score -= 3; + + // Rating suitability + if (restaurant.rating >= factors.minRating) { + score += 1; + } else { + score -= 2; + } + + return Math.max(1, Math.min(10, score)); + } + + /** + * Calculate mood match (1-10 scale) + */ + private calculateMoodMatch(restaurant: Restaurant, mood: string): number { + const moodKeywords = { + romantic: ['intimate', 'cozy', 'candlelit', 'wine', 'date', 'romantic'], + casual: ['casual', 'relaxed', 'friendly', 'laid-back', 'comfortable'], + upscale: ['upscale', 'elegant', 'sophisticated', 'fine', 'luxury'], + fun: ['lively', 'energetic', 'vibrant', 'entertainment', 'music'], + quiet: ['quiet', 'peaceful', 'serene', 'calm', 'tranquil'], + adventurous: ['unique', 'exotic', 'fusion', 'creative', 'innovative'], + traditional: [ + 'traditional', + 'authentic', + 'classic', + 'heritage', + 'original', + ], + }; + + const keywords = + moodKeywords[mood.toLowerCase() as keyof typeof moodKeywords] || []; + if (keywords.length === 0) return 5; // Default score + + let score = 5; // Base score + let matches = 0; + + // Check restaurant name, cuisine types, and reviews for mood keywords + const searchText = [ + restaurant.name, + ...restaurant.cuisineTypes, + ...(restaurant.reviews?.map(r => r.text) || []), + ] + .join(' ') + .toLowerCase(); + + for (const keyword of keywords) { + if (searchText.includes(keyword)) { + matches++; + } + } + + // Adjust score based on matches + if (matches > 0) { + score += Math.min(matches * 1.5, 4); // Cap at +4 + } + + // Consider price level for certain moods + if (restaurant.priceLevel) { + if (mood.toLowerCase() === 'upscale' && restaurant.priceLevel >= 3) { + score += 1; + } else if ( + mood.toLowerCase() === 'casual' && + restaurant.priceLevel <= 2 + ) { + score += 1; + } + } + + return Math.max(1, Math.min(10, score)); + } + + /** + * Generate human-readable reasoning for the recommendation + */ + private generateReasoning( + restaurant: Restaurant, + params: RestaurantSearchParams, + score: number, + eventSuitability: number, + moodMatch: number + ): string { + const reasons: string[] = []; + + // Rating and reviews + if (restaurant.rating >= 4.5) { + reasons.push( + `Excellent rating of ${restaurant.rating}/5 with ${restaurant.userRatingsTotal} reviews` + ); + } else if (restaurant.rating >= 4.0) { + reasons.push( + `High rating of ${restaurant.rating}/5 with ${restaurant.userRatingsTotal} reviews` + ); + } else if (restaurant.rating >= 3.5) { + reasons.push(`Good rating of ${restaurant.rating}/5`); + } + + // Cuisine match + if (params.cuisineTypes.length > 0) { + const matchingCuisines = restaurant.cuisineTypes.filter(rc => + params.cuisineTypes.some( + sc => + rc.toLowerCase().includes(sc.toLowerCase()) || + sc.toLowerCase().includes(rc.toLowerCase()) + ) + ); + if (matchingCuisines.length > 0) { + reasons.push( + `Serves ${matchingCuisines.join(', ')} cuisine as requested` + ); + } + } + + // Event suitability + if (eventSuitability >= 8) { + reasons.push(`Perfect for ${params.event}`); + } else if (eventSuitability >= 6) { + reasons.push(`Well-suited for ${params.event}`); + } + + // Mood match + if (moodMatch >= 8) { + reasons.push(`Excellent match for ${params.mood} mood`); + } else if (moodMatch >= 6) { + reasons.push(`Good fit for ${params.mood} atmosphere`); + } + + // Price level + if (restaurant.priceLevel) { + const priceLabels = [ + '', + 'Budget-friendly', + 'Moderately priced', + 'Upscale', + 'High-end', + ]; + reasons.push(priceLabels[restaurant.priceLevel]); + } + + // Opening hours + if (restaurant.openingHours?.openNow) { + reasons.push('Currently open'); + } + + return reasons.length > 0 + ? reasons.join('. ') + '.' + : 'Recommended based on location and general criteria.'; + } +} diff --git a/mcp-agents/mcp-booking-main/src/services/yelpService.ts b/mcp-agents/mcp-booking-main/src/services/yelpService.ts new file mode 100644 index 00000000..b2d195bb --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/services/yelpService.ts @@ -0,0 +1,157 @@ +import type { Restaurant } from '../types/index.js'; + +const YELP_SEARCH_URL = 'https://api.yelp.com/v3/businesses/search'; + +type YelpSearchBusiness = { + id: string; + alias: string; + name: string; + url: string; + distance?: number; +}; + +type YelpSearchResponse = { + businesses?: YelpSearchBusiness[]; +}; + +function nameMatchScore(target: string, candidate: string): number { + const t = target.toLowerCase().trim(); + const c = candidate.toLowerCase().trim(); + if (!t || !c) return 0; + if (t === c) return 1000; + if (c.includes(t)) return 800; + if (t.includes(c)) return 600; + const tWords = new Set(t.split(/\s+/).filter(Boolean)); + const cWords = new Set(c.split(/\s+/).filter(Boolean)); + let overlap = 0; + for (const w of tWords) { + if (cWords.has(w)) overlap += 1; + } + return overlap * 50; +} + +function menuUrlFromAlias(alias: string): string { + return `https://www.yelp.com/menu/${alias}`; +} + +function pickBestBusiness( + restaurant: Restaurant, + businesses: YelpSearchBusiness[] +): YelpSearchBusiness | null { + if (businesses.length === 0) return null; + + let best: YelpSearchBusiness | null = null; + let bestScore = -1; + + for (const b of businesses) { + const nameScore = nameMatchScore(restaurant.name, b.name); + const dist = b.distance; + const distBoost = + typeof dist === 'number' && !Number.isNaN(dist) + ? Math.max(0, 600 - dist) * 0.35 + : 80; + const total = nameScore + distBoost; + if (total > bestScore) { + bestScore = total; + best = b; + } + } + + if (!best) return null; + const nameScore = nameMatchScore(restaurant.name, best.name); + const distOk = typeof best.distance === 'number' && best.distance <= 250; + if (nameScore >= 120 || (nameScore >= 80 && distOk)) { + return best; + } + return null; +} + +export type YelpEnrichment = { + yelpBusinessId?: string; + yelpAlias?: string; + yelpUrl?: string; + yelpMenuUrl?: string; +}; + +export class YelpService { + constructor(private readonly apiKey: string | undefined) {} + + isEnabled(): boolean { + return Boolean(this.apiKey && this.apiKey.trim().length > 0); + } + + /** + * Find a Yelp business near the Google place and return profile + menu URLs. + * Menu is not returned as structured data by Yelp Fusion; we link to Yelp's menu page for the alias. + */ + async enrichRestaurant(restaurant: Restaurant): Promise { + if (!this.isEnabled()) return {}; + + const { latitude, longitude } = restaurant.location; + if ( + typeof latitude !== 'number' || + typeof longitude !== 'number' || + Number.isNaN(latitude) || + Number.isNaN(longitude) + ) { + return {}; + } + + const baseParams: Record = { + term: restaurant.name, + latitude: String(latitude), + longitude: String(longitude), + limit: '5', + radius: '1200', + }; + + try { + let businesses: YelpSearchBusiness[] = []; + for (const categories of ['restaurants', '']) { + const params = new URLSearchParams({ ...baseParams }); + if (categories) params.set('categories', categories); + const res = await fetch(`${YELP_SEARCH_URL}?${params.toString()}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.apiKey}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(12_000), + }); + + if (!res.ok) { + continue; + } + + const data = (await res.json()) as YelpSearchResponse; + businesses = data.businesses ?? []; + if (businesses.length > 0) break; + } + + if (businesses.length === 0) { + return {}; + } + + let match = pickBestBusiness(restaurant, businesses); + // Yelp already ranks by relevance to term + coordinates; if our strict + // name check fails, trust the top hit when it is very close. + if (!match && businesses[0]) { + const b = businesses[0]; + const d = b.distance; + if (typeof d === 'number' && d <= 350) { + match = b; + } + } + if (!match) return {}; + + return { + yelpBusinessId: match.id, + yelpAlias: match.alias, + yelpUrl: match.url, + yelpMenuUrl: menuUrlFromAlias(match.alias), + }; + } catch { + return {}; + } + } +} diff --git a/mcp-agents/mcp-booking-main/src/stripe_checkout.py b/mcp-agents/mcp-booking-main/src/stripe_checkout.py new file mode 100644 index 00000000..f75da261 --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/stripe_checkout.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +""" +Stripe Checkout for Agent Payment Protocol (Stripe rail). + +Reads STRIPE_* from the environment. Optionally merges values from the project +`.env` file (same pattern as innovation-lab-examples) so keys work when the +process is started without a shell that sourced `.env`. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path + +from dotenv import dotenv_values + +try: + import stripe +except ImportError: + stripe = None + +REPO_ROOT = Path(__file__).resolve().parents[1] +DOTENV_PATH = REPO_ROOT / ".env" + + +def _env_or_dotenv(key: str, default: str = "") -> str: + v = os.getenv(key) + if v is not None and str(v).strip() != "": + return str(v).strip() + if DOTENV_PATH.exists(): + dot = dotenv_values(DOTENV_PATH) + dv = dot.get(key) + if dv is not None and str(dv).strip() != "": + return str(dv).strip() + return default + + +def _cfg() -> dict: + secret_key = _env_or_dotenv("STRIPE_SECRET_KEY", "") + publishable_key = _env_or_dotenv("STRIPE_PUBLISHABLE_KEY", "") + try: + amount_cents = int(_env_or_dotenv("STRIPE_AMOUNT_CENTS", "50")) + except ValueError: + amount_cents = 50 + currency = ( + _env_or_dotenv("STRIPE_CURRENCY", "usd") or "usd" + ).lower().strip() or "usd" + product_name = ( + _env_or_dotenv("STRIPE_PRODUCT_NAME", "Restaurant discovery") + or "Restaurant discovery" + ).strip() + success_url = ( + _env_or_dotenv("STRIPE_SUCCESS_URL", "https://agentverse.ai") + or "https://agentverse.ai" + ).rstrip("/") + return { + "secret_key": secret_key, + "publishable_key": publishable_key, + "amount_cents": amount_cents, + "currency": currency, + "product_name": product_name, + "success_url": success_url, + } + + +def get_amount_cents() -> int: + return int(_cfg()["amount_cents"]) + + +def is_configured() -> bool: + c = _cfg() + return bool(stripe and c["secret_key"] and c["publishable_key"]) + + +def _get_stripe(): + if not stripe: + return None + stripe.api_key = _cfg()["secret_key"] + return stripe + + +def _expires_at() -> int: + try: + sec = int(_env_or_dotenv("STRIPE_CHECKOUT_EXPIRES_SECONDS", "1800")) + except ValueError: + sec = 1800 + sec = max(1800, min(24 * 3600, sec)) + return int(time.time()) + sec + + +def create_hosted_checkout_session( + *, + user_address: str, + chat_session_id: str, + description: str, +) -> dict | None: + """ + Hosted Checkout โ€” returns a normal https://checkout.stripe.com/... link + users can open from chat (Agent Payment Protocol UIs may still show a card). + """ + if not is_configured(): + return None + s = _get_stripe() + if not s: + return None + c = _cfg() + success_url = ( + f"{c['success_url']}" + f"?session_id={{CHECKOUT_SESSION_ID}}" + f"&chat_session_id={chat_session_id}" + f"&user={user_address}" + ) + try: + session = s.checkout.Session.create( + mode="payment", + payment_method_types=["card"], + success_url=success_url, + cancel_url=c["success_url"], + expires_at=_expires_at(), + line_items=[ + { + "price_data": { + "currency": c["currency"], + "product_data": { + "name": c["product_name"], + "description": description, + }, + "unit_amount": int(c["amount_cents"]), + }, + "quantity": 1, + } + ], + metadata={ + "user_address": user_address, + "session_id": chat_session_id, + "service": "restaurant_discovery", + }, + ) + url = getattr(session, "url", None) + if not url: + return None + return { + "checkout_url": url, + "checkout_session_id": session.id, + "publishable_key": c["publishable_key"], + "currency": c["currency"], + "amount_cents": int(c["amount_cents"]), + "ui_mode": "hosted", + } + except Exception: + return None + + +def create_embedded_checkout_session( + *, + user_address: str, + chat_session_id: str, + description: str, +) -> dict | None: + """ + Create a Stripe Checkout Session for embedded UI (Agentverse). + + Stripe's API may require ui_mode embedded_page; Agent Payment Protocol + examples still expose ui_mode \"embedded\" in metadata for the client UI. + """ + if not is_configured(): + return None + s = _get_stripe() + if not s: + return None + c = _cfg() + # Stripe API: newer accounts use embedded_page; keep overridable. + api_ui_mode = _env_or_dotenv("STRIPE_API_UI_MODE", "embedded_page") + return_url = ( + f"{c['success_url']}" + f"?session_id={{CHECKOUT_SESSION_ID}}" + f"&chat_session_id={chat_session_id}" + f"&user={user_address}" + ) + try: + session = s.checkout.Session.create( + ui_mode=api_ui_mode, + redirect_on_completion="if_required", + payment_method_types=["card"], + mode="payment", + return_url=return_url, + expires_at=_expires_at(), + line_items=[ + { + "price_data": { + "currency": c["currency"], + "product_data": { + "name": c["product_name"], + "description": description, + }, + "unit_amount": int(c["amount_cents"]), + }, + "quantity": 1, + } + ], + metadata={ + "user_address": user_address, + "session_id": chat_session_id, + "service": "restaurant_discovery", + }, + ) + client_secret = getattr(session, "client_secret", None) + checkout_id = session.id + return { + "client_secret": client_secret, + "checkout_session_id": checkout_id, + "publishable_key": c["publishable_key"], + "currency": c["currency"], + "amount_cents": int(c["amount_cents"]), + # Convention from Stripe Horoscope / Payment Protocol docs for UIs. + "ui_mode": "embedded", + } + except Exception: + return None + + +def verify_checkout_session_paid(checkout_session_id: str) -> bool: + if not is_configured(): + return False + s = _get_stripe() + if not s: + return False + try: + session = s.checkout.Session.retrieve(checkout_session_id) + return getattr(session, "payment_status", None) == "paid" + except Exception: + return False diff --git a/mcp-agents/mcp-booking-main/src/tests/restaurantRecommendationService.test.ts b/mcp-agents/mcp-booking-main/src/tests/restaurantRecommendationService.test.ts new file mode 100644 index 00000000..e4b3651d --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/tests/restaurantRecommendationService.test.ts @@ -0,0 +1,383 @@ +import { RestaurantRecommendationService } from '../services/restaurantRecommendationService.js'; +import { Restaurant, RestaurantSearchParams } from '../types/index.js'; + +describe('RestaurantRecommendationService', () => { + let service: RestaurantRecommendationService; + + beforeEach(() => { + service = new RestaurantRecommendationService(); + }); + + const createMockRestaurant = ( + overrides: Partial = {} + ): Restaurant => ({ + placeId: 'test-place', + name: 'Test Restaurant', + address: '123 Test St', + location: { latitude: 37.7749, longitude: -122.4194 }, + rating: 4.5, + userRatingsTotal: 100, + priceLevel: 2, + cuisineTypes: ['Italian'], + ...overrides, + }); + + const mockSearchParams: RestaurantSearchParams = { + location: { latitude: 37.7749, longitude: -122.4194 }, + cuisineTypes: ['Italian'], + mood: 'romantic', + event: 'dating', + radius: 2000, + }; + + describe('Parallel Processing', () => { + test('should process restaurants in parallel', async () => { + const restaurants = Array.from({ length: 10 }, (_, i) => + createMockRestaurant({ + placeId: `place_${i}`, + name: `Restaurant ${i}`, + rating: 4.0 + i / 10, // Varying ratings + }) + ); + + const startTime = Date.now(); + const recommendations = await service.getRecommendations( + restaurants, + mockSearchParams + ); + const duration = Date.now() - startTime; + + // Should complete quickly due to parallel processing + expect(duration).toBeLessThan(200); // Increased from 100ms to account for system load variations + expect(recommendations).toHaveLength(3); + + // Should be sorted by score (highest first) + expect(recommendations[0].score).toBeGreaterThanOrEqual( + recommendations[1].score + ); + expect(recommendations[1].score).toBeGreaterThanOrEqual( + recommendations[2].score + ); + }); + + test('should handle large datasets efficiently', async () => { + const restaurants = Array.from({ length: 100 }, (_, i) => + createMockRestaurant({ + placeId: `place_${i}`, + name: `Restaurant ${i}`, + rating: 3.0 + Math.random() * 2, // Random ratings between 3-5 + priceLevel: (Math.floor(Math.random() * 4) + 1) as 1 | 2 | 3 | 4, + cuisineTypes: i % 2 === 0 ? ['Italian'] : ['Japanese'], + }) + ); + + const startTime = Date.now(); + const recommendations = await service.getRecommendations( + restaurants, + mockSearchParams + ); + const duration = Date.now() - startTime; + + // Should handle 100 restaurants efficiently + expect(duration).toBeLessThan(300); // Increased from 200ms to account for processing 100 restaurants with system load variations + expect(recommendations).toHaveLength(3); + }); + }); + + describe('Scoring Algorithm', () => { + test('should score restaurants based on rating', () => { + const highRatedRestaurant = createMockRestaurant({ + rating: 4.8, + userRatingsTotal: 500, + }); + + const lowRatedRestaurant = createMockRestaurant({ + rating: 3.2, + userRatingsTotal: 50, + }); + + const highScore = (service as any).calculateRestaurantScore( + highRatedRestaurant, + mockSearchParams + ); + const lowScore = (service as any).calculateRestaurantScore( + lowRatedRestaurant, + mockSearchParams + ); + + expect(highScore).toBeGreaterThan(lowScore); + }); + + test('should score cuisine matches higher', () => { + const matchingRestaurant = createMockRestaurant({ + cuisineTypes: ['Italian', 'Mediterranean'], + }); + + const nonMatchingRestaurant = createMockRestaurant({ + cuisineTypes: ['Chinese', 'Asian'], + }); + + const matchingScore = (service as any).calculateRestaurantScore( + matchingRestaurant, + mockSearchParams + ); + const nonMatchingScore = (service as any).calculateRestaurantScore( + nonMatchingRestaurant, + mockSearchParams + ); + + expect(matchingScore).toBeGreaterThan(nonMatchingScore); + }); + + test('should consider event suitability', () => { + const datingRestaurant = createMockRestaurant({ + priceLevel: 3, + cuisineTypes: ['Italian', 'Fine Dining'], + rating: 4.5, + }); + + const casualRestaurant = createMockRestaurant({ + priceLevel: 1, + cuisineTypes: ['Fast Food'], + rating: 4.5, + }); + + const datingSuitability = (service as any).calculateEventSuitability( + datingRestaurant, + 'dating' + ); + const casualSuitability = (service as any).calculateEventSuitability( + casualRestaurant, + 'dating' + ); + + expect(datingSuitability).toBeGreaterThan(casualSuitability); + }); + + test('should consider mood matching', () => { + const romanticRestaurant = createMockRestaurant({ + name: 'Romantic Candlelit Restaurant', + cuisineTypes: ['French', 'Fine Dining'], + priceLevel: 4, + }); + + const casualRestaurant = createMockRestaurant({ + name: 'Sports Bar Grill', + cuisineTypes: ['American'], + priceLevel: 2, + }); + + const romanticMoodMatch = (service as any).calculateMoodMatch( + romanticRestaurant, + 'romantic' + ); + const casualMoodMatch = (service as any).calculateMoodMatch( + casualRestaurant, + 'romantic' + ); + + expect(romanticMoodMatch).toBeGreaterThan(casualMoodMatch); + }); + }); + + describe('Event Suitability', () => { + test('should recommend appropriate restaurants for dating', () => { + const restaurants = [ + createMockRestaurant({ + placeId: 'fine-dining', + name: 'Elegant Fine Dining', + priceLevel: 4, + cuisineTypes: ['French', 'Fine Dining'], + rating: 4.7, + }), + createMockRestaurant({ + placeId: 'fast-food', + name: 'Quick Burger Joint', + priceLevel: 1, + cuisineTypes: ['Fast Food'], + rating: 4.2, + }), + ]; + + const fineDiningSuitability = (service as any).calculateEventSuitability( + restaurants[0], + 'dating' + ); + const fastFoodSuitability = (service as any).calculateEventSuitability( + restaurants[1], + 'dating' + ); + + expect(fineDiningSuitability).toBeGreaterThan(fastFoodSuitability); + }); + + test('should recommend appropriate restaurants for business meetings', () => { + const businessRestaurant = createMockRestaurant({ + priceLevel: 3, + cuisineTypes: ['American', 'Steakhouse'], + rating: 4.5, + }); + + const casualRestaurant = createMockRestaurant({ + priceLevel: 1, + cuisineTypes: ['Pizza'], + rating: 4.5, + }); + + const businessSuitability = (service as any).calculateEventSuitability( + businessRestaurant, + 'business' + ); + const casualSuitability = (service as any).calculateEventSuitability( + casualRestaurant, + 'business' + ); + + expect(businessSuitability).toBeGreaterThan(casualSuitability); + }); + }); + + describe('Reasoning Generation', () => { + test('should generate comprehensive reasoning', () => { + const restaurant = createMockRestaurant({ + rating: 4.6, + userRatingsTotal: 250, + cuisineTypes: ['Italian'], + priceLevel: 3, + openingHours: { + openNow: true, + weekdayText: ['Monday: 5:00 PM โ€“ 10:00 PM'], + }, + }); + + const reasoning = (service as any).generateReasoning( + restaurant, + mockSearchParams, + 85, // High score + 8, // High event suitability + 7 // Good mood match + ); + + expect(reasoning).toContain('Excellent rating'); + expect(reasoning).toContain('Italian cuisine'); + expect(reasoning).toContain('Perfect for dating'); + expect(reasoning).toContain('Currently open'); + }); + + test('should handle restaurants with missing data', () => { + const restaurant = createMockRestaurant({ + rating: 0, + userRatingsTotal: 0, + priceLevel: undefined, + }); + + const reasoning = (service as any).generateReasoning( + restaurant, + mockSearchParams, + 30, // Low score + 5, // Average event suitability + 5 // Average mood match + ); + + expect(reasoning).toBeTruthy(); + expect(reasoning.length).toBeGreaterThan(0); + }); + }); + + describe('Edge Cases', () => { + test('should handle empty restaurant list', async () => { + const recommendations = await service.getRecommendations( + [], + mockSearchParams + ); + expect(recommendations).toHaveLength(0); + }); + + test('should handle restaurants with identical scores', async () => { + const restaurants = Array.from({ length: 5 }, (_, i) => + createMockRestaurant({ + placeId: `identical_${i}`, + name: `Identical Restaurant ${i}`, + rating: 4.5, + userRatingsTotal: 100, + priceLevel: 2, + cuisineTypes: ['Italian'], + }) + ); + + const recommendations = await service.getRecommendations( + restaurants, + mockSearchParams + ); + expect(recommendations).toHaveLength(3); + + // All should have similar scores + const scores = recommendations.map(r => r.score); + const maxScore = Math.max(...scores); + const minScore = Math.min(...scores); + expect(maxScore - minScore).toBeLessThan(5); // Small variance + }); + + test('should handle missing search criteria', async () => { + const restaurants = [createMockRestaurant()]; + const emptyParams: RestaurantSearchParams = { + cuisineTypes: [], + mood: '', + event: '', + }; + + const recommendations = await service.getRecommendations( + restaurants, + emptyParams + ); + expect(recommendations).toHaveLength(1); + expect(recommendations[0].score).toBeGreaterThan(0); + }); + }); + + describe('Performance Benchmarks', () => { + test('should maintain performance with varying restaurant counts', async () => { + const testSizes = [5, 25, 50, 100]; + const timings: number[] = []; + + for (const size of testSizes) { + const restaurants = Array.from({ length: size }, (_, i) => + createMockRestaurant({ + placeId: `bench_${i}`, + rating: 3 + Math.random() * 2, + }) + ); + + const startTime = Date.now(); + await service.getRecommendations(restaurants, mockSearchParams); + const duration = Date.now() - startTime; + + timings.push(duration); + } + + // Performance should scale reasonably (not exponentially) + // Allow more generous scaling for small sample sizes since timing can be inconsistent + expect(timings[3]).toBeLessThan(Math.max(timings[0] * 100, 50)); // 100 items shouldn't take 100x longer than 5 items or more than 50ms + }); + + test('should handle concurrent recommendation requests', async () => { + const restaurants = Array.from({ length: 20 }, (_, i) => + createMockRestaurant({ placeId: `concurrent_${i}` }) + ); + + const concurrentRequests = Array(5) + .fill(null) + .map(() => service.getRecommendations(restaurants, mockSearchParams)); + + const startTime = Date.now(); + const results = await Promise.all(concurrentRequests); + const duration = Date.now() - startTime; + + expect(duration).toBeLessThan(500); // Should handle 5 concurrent requests quickly + expect(results).toHaveLength(5); + results.forEach(result => { + expect(result).toHaveLength(3); + }); + }); + }); +}); diff --git a/mcp-agents/mcp-booking-main/src/tests/setup.ts b/mcp-agents/mcp-booking-main/src/tests/setup.ts new file mode 100644 index 00000000..5acdd8bb --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/tests/setup.ts @@ -0,0 +1,88 @@ +// Test setup file for Jest +// This file runs before each test file + +// Set longer timeout for performance tests +jest.setTimeout(30000); + +// Mock environment variables +process.env.NODE_ENV = 'test'; +process.env.GOOGLE_MAPS_API_KEY = 'test-api-key'; + +// Console suppression for cleaner test output +const originalConsoleError = console.error; +const originalConsoleWarn = console.warn; + +beforeEach(() => { + // Suppress console errors/warnings during tests unless debugging + if (!process.env.DEBUG_TESTS) { + console.error = jest.fn(); + console.warn = jest.fn(); + } +}); + +afterEach(() => { + // Restore console functions + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + + // Clear all mocks after each test + jest.clearAllMocks(); +}); + +// Export utilities instead of adding to global +export const createMockLocation = (lat = 37.7749, lng = -122.4194) => ({ + latitude: lat, + longitude: lng, +}); + +export const createMockRestaurant = ( + overrides: Record = {} +) => ({ + placeId: 'test-place', + name: 'Test Restaurant', + address: '123 Test Street', + location: { latitude: 37.7749, longitude: -122.4194 }, + rating: 4.5, + userRatingsTotal: 100, + priceLevel: 2, + cuisineTypes: ['Italian'], + googleMapsUrl: 'https://maps.google.com/test', + ...overrides, +}); + +// Performance monitoring helpers +export const measureExecutionTime = async (fn: () => Promise) => { + const startTime = Date.now(); + const result = await fn(); + const duration = Date.now() - startTime; + return { result, duration }; +}; + +// Memory usage helpers +export const getMemoryUsage = () => { + const usage = process.memoryUsage(); + return { + heapUsed: Math.round(usage.heapUsed / 1024 / 1024), // MB + heapTotal: Math.round(usage.heapTotal / 1024 / 1024), // MB + external: Math.round(usage.external / 1024 / 1024), // MB + rss: Math.round(usage.rss / 1024 / 1024), // MB + }; +}; + +/** + * Enable garbage collection for memory leak tests + * + * The global.gc function is only available when Node.js is started with --expose-gc flag. + * In testing environments, we provide a no-op fallback to avoid runtime errors. + * + * Note: @types/node defines gc as NodeJS.GCFunction which can return Promise, + * but the actual implementation can be synchronous. We provide a compatible fallback. + */ +if (typeof global.gc === 'undefined') { + // Type assertion is necessary here because we're providing a fallback implementation + // for the gc function when it's not exposed via --expose-gc flag + global.gc = (() => { + // No-op fallback when garbage collection is not exposed + // In CI/testing environments without --expose-gc flag + }) as NodeJS.GCFunction; +} diff --git a/mcp-agents/mcp-booking-main/src/types/index.ts b/mcp-agents/mcp-booking-main/src/types/index.ts new file mode 100644 index 00000000..440adb14 --- /dev/null +++ b/mcp-agents/mcp-booking-main/src/types/index.ts @@ -0,0 +1,203 @@ +export interface Location { + latitude: number; + longitude: number; +} + +export interface RestaurantSearchParams { + location?: Location; // Made optional when placeName is provided + placeName?: string; // ๐Ÿ†• NEW: Alternative to location - place name to geocode + cuisineTypes: string[]; + mood: string; + event: string; + radius?: number; // in meters, default 20km + priceLevel?: 1 | 2 | 3 | 4; // 1 = inexpensive, 4 = very expensive + keyword?: string; // ๐Ÿ†• NEW: Search for specific food types like "hotpot", "sushi", "pizza", etc. + locale?: string; // ๐Ÿ†• NEW: Locale for search results (e.g., "en", "zh-TW", "ja", "ko") + strictCuisineFiltering?: boolean; // ๐Ÿ†• NEW: If true, exclude restaurants that don't match cuisine criteria +} + +export interface Restaurant { + placeId: string; + name: string; + address: string; + location: Location; + rating: number; + userRatingsTotal: number; + priceLevel?: number; + cuisineTypes: string[]; + photos?: string[]; + phoneNumber?: string; + website?: string; + googleMapsUrl?: string; + /** Yelp Fusion match (optional; set when YELP_API_KEY is configured) */ + yelpBusinessId?: string; + yelpAlias?: string; + yelpUrl?: string; + /** Yelp menu page for this business (alias-based URL) */ + yelpMenuUrl?: string; + distance?: number; // ๐Ÿ†• NEW: Distance from search location in meters + bookingInfo?: { + reservable?: boolean; + bookingUrl?: string; + bookingPlatform?: + | 'opentable' + | 'resy' + | 'yelp' + | 'restaurant_website' + | 'google_reserve' + | 'other'; + supportsOnlineBooking?: boolean; + requiresPhone?: boolean; + }; + reservable?: boolean; + curbsidePickup?: boolean; + delivery?: boolean; + dineIn?: boolean; + takeout?: boolean; + servesBreakfast?: boolean; + servesLunch?: boolean; + servesDinner?: boolean; + servesBrunch?: boolean; + servesBeer?: boolean; + servesWine?: boolean; + servesVegetarianFood?: boolean; + openingHours?: { + openNow: boolean; + periods?: Array<{ + open: { day: number; time: string }; + close?: { day: number; time: string }; + }>; + weekdayText?: string[]; + }; + reviews?: Array<{ + authorName: string; + rating: number; + text: string; + time: number; + }>; +} + +export interface RestaurantRecommendation { + restaurant: Restaurant; + score: number; + reasoning: string; + suitabilityForEvent: number; // 1-10 scale + moodMatch: number; // 1-10 scale +} + +export interface BookingRequest { + restaurant: Restaurant; + partySize: number; + preferredDateTime: string; // ISO string + specialRequests?: string; + contactInfo: { + name: string; + phone: string; + email?: string; + }; +} + +export interface BookingResponse { + success: boolean; + bookingId?: string; + confirmationDetails?: string; + message: string; + alternativeOptions?: Restaurant[]; +} + +export interface GooglePlacesResponse { + results: Array<{ + place_id: string; + name: string; + formatted_address: string; + geometry: { + location: { + lat: number; + lng: number; + }; + }; + rating?: number; + user_ratings_total?: number; + price_level?: number; + types: string[]; + photos?: Array<{ + photo_reference: string; + height: number; + width: number; + }>; + opening_hours?: { + open_now: boolean; + }; + }>; + status: string; + next_page_token?: string; +} + +export interface GooglePlaceDetailsResponse { + result: { + place_id: string; + name: string; + formatted_address: string; + formatted_phone_number?: string; + website?: string; + geometry: { + location: { + lat: number; + lng: number; + }; + }; + rating?: number; + user_ratings_total?: number; + price_level?: number; + types: string[]; + photos?: Array<{ + photo_reference: string; + height: number; + width: number; + }>; + reservable?: boolean; + curbside_pickup?: boolean; + delivery?: boolean; + dine_in?: boolean; + takeout?: boolean; + serves_breakfast?: boolean; + serves_lunch?: boolean; + serves_dinner?: boolean; + serves_brunch?: boolean; + serves_beer?: boolean; + serves_wine?: boolean; + serves_vegetarian_food?: boolean; + opening_hours?: { + open_now: boolean; + periods?: Array<{ + open: { day: number; time: string }; + close?: { day: number; time: string }; + }>; + weekday_text?: string[]; + }; + reviews?: Array<{ + author_name: string; + rating: number; + text: string; + time: number; + }>; + }; + status: string; +} + +// ๐Ÿ†• NEW: Google Geocoding API response interface +export interface GoogleGeocodingResponse { + results: Array<{ + formatted_address: string; + geometry: { + location: { + lat: number; + lng: number; + }; + location_type: string; + }; + place_id: string; + types: string[]; + }>; + status: string; +} diff --git a/mcp-agents/mcp-booking-main/test-mcp-server.sh b/mcp-agents/mcp-booking-main/test-mcp-server.sh new file mode 100755 index 00000000..5be9ccd1 --- /dev/null +++ b/mcp-agents/mcp-booking-main/test-mcp-server.sh @@ -0,0 +1,270 @@ +#!/bin/bash + +# MCP Restaurant Booking Server Test Script +# This script tests all the endpoints and tools of the MCP server + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Server configuration +SERVER_URL="http://localhost:3001" +MCP_ENDPOINT="$SERVER_URL/mcp" +HEALTH_ENDPOINT="$SERVER_URL/health" + +# Function to print colored output +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +# Function to extract session ID from response +extract_session_id() { + local response="$1" + echo "$response" | grep -o 'mcp-session-id: [^[:space:]]*' | cut -d' ' -f2 | tr -d '\r' +} + +# Function to make MCP request +make_mcp_request() { + local session_id="$1" + local data="$2" + local description="$3" + + print_status "Testing: $description" + + if [ -z "$session_id" ]; then + # Initial request without session ID + curl -s -i -X POST "$MCP_ENDPOINT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d "$data" + else + # Request with session ID + curl -s -X POST "$MCP_ENDPOINT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "mcp-session-id: $session_id" \ + -d "$data" + fi +} + +echo "==================================" +echo "MCP Restaurant Booking Server Test" +echo "==================================" + +# Step 1: Health Check +print_status "Checking server health..." +health_response=$(curl -s "$HEALTH_ENDPOINT") +if echo "$health_response" | grep -q '"status":"ok"'; then + print_success "Server is healthy" + echo "$health_response" | jq . 2>/dev/null || echo "$health_response" +else + print_error "Server health check failed" + echo "$health_response" + exit 1 +fi + +echo "" + +# Step 2: Initialize MCP Session +print_status "Initializing MCP session..." +init_data='{ + "jsonrpc": "2.0", + "id": "init", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } +}' + +init_response=$(make_mcp_request "" "$init_data" "Session initialization") +session_id=$(extract_session_id "$init_response") + +if [ -n "$session_id" ]; then + print_success "Session initialized with ID: $session_id" +else + print_error "Failed to extract session ID" + echo "$init_response" + exit 1 +fi + +echo "" + +# Step 3: List Available Tools +print_status "Listing available tools..." +list_tools_data='{ + "jsonrpc": "2.0", + "id": "list-tools", + "method": "tools/list" +}' + +tools_response=$(make_mcp_request "$session_id" "$list_tools_data" "Tools listing") +echo "$tools_response" | jq . 2>/dev/null || echo "$tools_response" +print_success "Tools listed successfully" + +echo "" + +# Step 4: Search Restaurants +print_status "Testing restaurant search..." +search_data='{ + "jsonrpc": "2.0", + "id": "search", + "method": "tools/call", + "params": { + "name": "search_restaurants", + "arguments": { + "mood": "romantic", + "event": "dating", + "latitude": 25.0330, + "longitude": 121.5654, + "radius": 2000, + "locale": "en" + } + } +}' + +search_response=$(make_mcp_request "$session_id" "$search_data" "Restaurant search") +echo "$search_response" | jq . 2>/dev/null || echo "$search_response" + +# Extract place ID from search response for next test +place_id=$(echo "$search_response" | grep -o '"placeId":"[^"]*"' | head -1 | cut -d'"' -f4) +if [ -n "$place_id" ]; then + print_success "Restaurant search completed. Found place ID: $place_id" +else + print_warning "Could not extract place ID from search results" + place_id="ChIJz8_rGbyrQjQRD1-qfRm5C1M" # Fallback to known place ID +fi + +echo "" + +# Step 5: Get Restaurant Details +print_status "Testing restaurant details retrieval..." +details_data="{ + \"jsonrpc\": \"2.0\", + \"id\": \"details\", + \"method\": \"tools/call\", + \"params\": { + \"name\": \"get_restaurant_details\", + \"arguments\": { + \"placeId\": \"$place_id\", + \"locale\": \"en\" + } + } +}" + +details_response=$(make_mcp_request "$session_id" "$details_data" "Restaurant details") +echo "$details_response" | jq . 2>/dev/null || echo "$details_response" +print_success "Restaurant details retrieved successfully" + +echo "" + +# Step 6: Get Booking Instructions +print_status "Testing booking instructions..." +booking_data="{ + \"jsonrpc\": \"2.0\", + \"id\": \"booking\", + \"method\": \"tools/call\", + \"params\": { + \"name\": \"get_booking_instructions\", + \"arguments\": { + \"placeId\": \"$place_id\", + \"locale\": \"en\" + } + } +}" + +booking_response=$(make_mcp_request "$session_id" "$booking_data" "Booking instructions") +echo "$booking_response" | jq . 2>/dev/null || echo "$booking_response" +print_success "Booking instructions retrieved successfully" + +echo "" + +# Step 7: Check Availability +print_status "Testing availability check..." +availability_data="{ + \"jsonrpc\": \"2.0\", + \"id\": \"availability\", + \"method\": \"tools/call\", + \"params\": { + \"name\": \"check_availability\", + \"arguments\": { + \"placeId\": \"$place_id\", + \"dateTime\": \"2024-12-25T19:00:00\", + \"partySize\": 2, + \"locale\": \"en\" + } + } +}" + +availability_response=$(make_mcp_request "$session_id" "$availability_data" "Availability check") +echo "$availability_response" | jq . 2>/dev/null || echo "$availability_response" +print_success "Availability check completed successfully" + +echo "" + +# Step 8: Make Reservation (Test) +print_status "Testing reservation creation..." +reservation_data="{ + \"jsonrpc\": \"2.0\", + \"id\": \"reservation\", + \"method\": \"tools/call\", + \"params\": { + \"name\": \"make_reservation\", + \"arguments\": { + \"placeId\": \"$place_id\", + \"partySize\": 2, + \"preferredDateTime\": \"2024-12-25T19:00:00\", + \"contactName\": \"Sam Wang\", + \"contactPhone\": \"+1234567890\", + \"contactEmail\": \"samxxxx@gmail.com\", + \"specialRequests\": \"Window seat preferred\", + \"locale\": \"en\" + } + } +}" + +reservation_response=$(make_mcp_request "$session_id" "$reservation_data" "Reservation creation") +echo "$reservation_response" | jq . 2>/dev/null || echo "$reservation_response" +print_success "Reservation test completed successfully" + +echo "" + +# Summary +print_success "All tests completed successfully!" +echo "==================================" +echo "Test Summary:" +echo "โœ… Health Check" +echo "โœ… Session Initialization" +echo "โœ… Tools Listing" +echo "โœ… Restaurant Search" +echo "โœ… Restaurant Details" +echo "โœ… Booking Instructions" +echo "โœ… Availability Check" +echo "โœ… Reservation Test" +echo "==================================" +echo "" +print_status "Session ID for manual testing: $session_id" +print_warning "Note: The reservation test is for API testing only. No actual reservation was made." \ No newline at end of file diff --git a/mcp-agents/mcp-booking-main/tsconfig.json b/mcp-agents/mcp-booking-main/tsconfig.json new file mode 100644 index 00000000..d5912eda --- /dev/null +++ b/mcp-agents/mcp-booking-main/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "sourceMap": true, + "declaration": true, + "baseUrl": "./src", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}