Skip to main content
Glama
francisfan0

Yale Dining MCP

by francisfan0
README.md
# Yale Dining MCP

Lightweight TypeScript service for Yale Hospitality menus and Payne Whitney Gym hours:

1. An **MCP server** (`@modelcontextprotocol/sdk`) with tools for LLMs (stdio or Streamable HTTP).
2. A **REST gateway** (`POST /api/chat`) for an iOS Shortcut — built for Siri speech and notification text.

Menus come from the public Nutrislice API. Gym hours and closures are parsed from [PWG Hours of Operation](https://recreation.yale.edu/pwghours).

## Host on Vercel (iOS Shortcut)

The Mac does not need to stay on. Vercel Hobby is free HTTPS and fits this app better than Cloudflare Workers (Nutrislice week JSON is large; Workers CPU limits are tight).

```bash
npm i -g vercel
vercel login
vercel --prod
```

When prompted, set environment variables (or add them in the Vercel dashboard → Settings → Environment Variables):

- `GROQ_API_KEY` — from [console.groq.com/keys](https://console.groq.com/keys)
- `LLM_BASE_URL` = `https://api.groq.com/openai/v1`
- `LLM_MODEL` = `openai/gpt-oss-120b`
- `MCP_AUTH_TOKEN` — optional, only if you expose `/mcp` publicly

Your Shortcut URL:

```text
https://yale-dining.vercel.app/api/chat
```

Health check: `https://yale-dining.vercel.app/health`

Cloudflare Tunnel still runs on your laptop. A Cloudflare Worker rewrite is possible later, but Vercel Node is the path that matches this codebase without a computer left on.

## Setup

Requires Node.js 20+.

```bash
npm install
cp .env.example .env
```

### Language model

The chat gateway talks to any OpenAI-compatible API. **Groq is the default.**

Create a free key at [console.groq.com/keys](https://console.groq.com/keys), then in `.env`:

```
GROQ_API_KEY=gsk_...
LLM_BASE_URL=https://api.groq.com/openai/v1
LLM_MODEL=openai/gpt-oss-120b
```

`openai/gpt-oss-120b` is Groq’s current replacement for the retired Llama 3.3 IDs and supports tool calling. If Groq is down or the key is missing, the gateway still answers common questions (college + meal, “where is pizza”) by routing directly to Nutrislice.

**Ollama (local)**

```
LLM_BASE_URL=http://localhost:11434/v1
LLM_API_KEY=ollama
LLM_MODEL=llama3.1
```

**OpenAI**

```
LLM_BASE_URL=https://api.openai.com/v1
LLM_API_KEY=sk-...
LLM_MODEL=gpt-4o-mini
```

## Run

```bash
npm run dev
```

Listens on `http://0.0.0.0:3000`.

- Health: `GET /health`
- iOS chat: `POST /api/chat`
- MCP Streamable HTTP: `POST /mcp`

MCP over stdio (local Claude Desktop / Cursor):

```bash
npm run mcp
```

### Docker

```bash
docker build -t yale-dining .
docker run --rm -p 3000:3000 \
  -e GROQ_API_KEY=gsk_... \
  -e MCP_AUTH_TOKEN=optional-secret \
  yale-dining
```

Set `MCP_AUTH_TOKEN` if the container is on a public URL so `/mcp` requires `Authorization: Bearer <token>`.

Example chat request:

```bash
curl -X POST http://localhost:3000/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"query":"What'\''s for lunch at Berkeley College today?"}'
```

Response:

```json
{
  "reply": "Lunch at Berkeley College today includes grilled chicken, quinoa salad, and roasted vegetables."
}
```

## MCP tools

| Tool | Arguments | Purpose |
| --- | --- | --- |
| `list_colleges` | none | All 14 residential colleges and Nutrislice slugs |
| `get_college_menu` | `college_slug`, `meal`, `date?` | One hall’s dishes for breakfast, lunch, or dinner (date defaults to today, Eastern Time) |
| `search_dishes` | `query`, `meal?`, `date?` | Search every residential college in parallel (`Promise.all`) |
| `get_pwg_hours` | `date?`, `space?` | Payne Whitney gym, pool, and squash hours |
| `get_pwg_closures` | `date?` | Closures and adjusted hours (recess, holidays, varsity meets) |
| `get_activity_space_schedule` | none | Lanman / rec-court open-rec policy and 25Live schedule URL |

Cursor / Claude Desktop (stdio, local):

```json
{
  "mcpServers": {
    "yale-dining": {
      "command": "npx",
      "args": ["tsx", "src/mcp-server.ts"],
      "cwd": "/absolute/path/to/Dining"
    }
  }
}
```

Remote Streamable HTTP (Docker, Fly, or the same process as `/api/chat`):

```json
{
  "mcpServers": {
    "yale-dining": {
      "url": "http://localhost:3000/mcp",
      "headers": {
        "Authorization": "Bearer optional-secret"
      }
    }
  }
}
```

Omit `headers` if `MCP_AUTH_TOKEN` is unset.

## iOS Shortcut setup

Use the Vercel HTTPS URL (the Mac does not need to be on):

`https://yale-dining.vercel.app/api/chat`

1. Open **Shortcuts** on iOS.
2. Create a new shortcut named **Yale Dining**.
3. Add action: **Ask for Input** → *Text* with prompt *What would you like to ask Yale Dining?* (Skip this step if Siri should pass the spoken question directly.)
4. Add action: **Get Contents of URL**
   - URL: `https://yale-dining.vercel.app/api/chat`
   - Method: **POST**
   - Headers: `Content-Type` = `application/json`
   - Request Body: **JSON**
   - Key: `query` → Value: *Provided Input*
5. Add action: **Get Dictionary Value** (or **Get Value for Key**) → Key: `reply`
6. Add action: **Show Result** (widget / screen) or **Speak Text** (Siri).

Optional Siri phrase: in shortcut details, set **Use with Siri** and a phrase such as “Yale Dining”.

## Project layout

```text
src/
  nutrislice.ts   # Nutrislice fetchers and menu parser
  pwg.ts          # Payne Whitney hours, closures, activity-space links
  tools.ts        # Shared MCP/LLM tool router
  mcp-server.ts   # MCP tool schemas and stdio server
  llm.ts          # Groq/OpenAI function calling + spoken-reply formatting
  http.ts         # Web Standard handlers shared by Fastify and Vercel
  server.ts       # Local Fastify POST /api/chat and POST /mcp
api/              # Vercel functions
Dockerfile        # Optional container image
vercel.json       # Vercel routes and timeouts
```