# Claude Connector (MCP)

## What it does

Lets a staff member connect **claude.ai** to this system and ask it anything in plain words:
"how many new leads came in this month?", "what does `/manage/memberships` show?", "which
project had the most bookings?". Claude answers by reading two things, **both read-only**:

- the **LIVE production database**: every table, over a SELECT-only MySQL account;
- the **source code deployed on production**: an allow-list of folders, with secrets refused.

It cannot change, add or delete anything. Access is per person: the
`use-claude-connector` permission (Roles page → *AI & System → Claude Connector*). By default
only Super Admin holds it.

> ⚠️ **It is every read permission at once.** Claude reads the whole database, unscoped:
> `LeadVisibility`, group scoping and the Owner Listing gate do not apply. Every lead's phone
> and email, IC numbers and payments are all readable. Grant it to named people only, the
> same way as `view-owner-listing`. **Nothing is logged by design:** the owner chose not to
> track who asked what, so there is no audit trail of questions or queries.

## How it works

- **Protocol.** [MCP](https://modelcontextprotocol.io) over HTTP, served by `laravel/mcp` at
  `POST /mcp/sales` (`routes/ai.php`, which the package loads, so it gets no `web` middleware
  group).
- **Sign-in is OAuth 2.1 (Laravel Passport)**, and claude.ai walks it by itself:
  1. `POST /mcp/sales` without a token → **401** with a `WWW-Authenticate` header pointing at
     `/.well-known/oauth-protected-resource/mcp/sales` → `/.well-known/oauth-authorization-server`.
  2. claude.ai registers itself: `POST /oauth/register` (dynamic client registration). Only
     `https://claude.ai` / `https://claude.com` callbacks are accepted (`config/mcp.php`;
     `MCP_EXTRA_REDIRECT_DOMAINS` adds more, e.g. `http://localhost` for the MCP Inspector).
  3. The browser opens `/oauth/authorize`. A signed-out user is sent to **`/manage/login`**
     (`App\Exceptions\Handler::unauthenticated`), then comes back to the **Allow screen**
     (`Pages/Auth/OAuthAuthorize.vue`, rendered for Passport in
     `AuthServiceProvider::registerPassport()`). Someone without the permission sees an
     explanation and only a Cancel button.
  4. Allow → a code goes back to claude.ai → `POST /oauth/token` (PKCE) → access token
     (1 day) + refresh token (60 days). claude.ai renews the token silently.
     ⚠️ The Allow screen's `auth_token` is **single-use**: the first post pulls it from the
     session. A second post (double-click, Back + Allow, two Connect tabs) gets Passport's
     **403 "The provided auth token for the request is different from the session auth
     token"**, and the browser shows that error instead of the redirect. The buttons disable
     themselves after the first click; if it still happens, close the tab and press Connect
     in claude.ai again.
- **Every call is re-checked.** `auth:mcp` (the `passport` guard in `config/auth.php`) resolves
  the user through the `gsc` provider, so a banned, inactive or merged account is refused.
  `claude.connector` (`EnsureClaudeConnectorAccess`) then requires staff + the permission.
  **Removing the permission cuts a connected Claude off on its next call**, with no token
  revocation needed. It checks the permission against guard `web` explicitly, because spatie's
  `permission:` middleware looks for an `mcp`-guard permission under this guard and refuses
  everyone.
- **Tools** (`app/Mcp/Tools`, registered on `App\Mcp\Servers\PetaServer`, whose `#[Instructions]`
  teach Claude the schema conventions and the order to answer in):

  | Tool | Does |
  |---|---|
  | `list_tables` | tables of a schema, with approximate row counts |
  | `describe_table` | columns + indexes of one table |
  | `run_query` | one read statement → at most 500 rows, JSON, plus `now_malaysia` |
  | `search_code` | case-insensitive text / ERE search → `path:line: text`, at most 100 |
  | `read_file` | a file with line numbers, 800 lines per call |
  | `list_files` | a folder's entries |

- **Database locks** (`App\Mcp\Support\ProductionDatabase`, connection `production_read` in
  `config/database.php`, credentials `PETAV3_READ_DB_*`):
  1. the MySQL account holds **SELECT only**. This is the real guarantee (`SHOW GRANTS` on
     `petav3_read`: `SELECT` on `petav3`, `petav3ipg`, `master_projects`, `master_projects_dev`);
  2. each session opens with `transaction_read_only = 1`, a second lock should the account ever
     be widened;
  3. `max_execution_time = 30000`: one runaway question cannot slow the live site for long;
  4. statements must start with SELECT / WITH / SHOW / DESCRIBE / DESC / EXPLAIN. This only
     gives a clear message; a CTE that ends in DELETE passes it and is refused by (1) and (2);
  5. rows stream through an **unbuffered** cursor and stop at 500 rows / 200 KB, and cells are
     cut at 2,000 characters, so a careless `SELECT *` cannot exhaust PHP memory.
- **Code locks** (`App\Mcp\Support\SourceCode`): readable folders are `app src diver routes
  config database resources docs tests lang` plus a few root files (`CLAUDE.md`,
  `GUIDELINES.md`, …). Names matching `.env*`, `*.key`, `*.pem`, `*.p12`, `auth.json` and `.git*`
  are refused everywhere; `..` and symlinks that lead outside the project are refused.
  `storage/`, `vendor/`, `node_modules/` and `bootstrap/` are simply not on the list.
  `search_code` uses **`git grep`** (about 1 s, TRACKED files only) with `-c safe.directory`,
  because the web user does not own the checkout. It falls back to a PHP scan (20–30 s) where
  git is unavailable.
- **Times.** Stored datetimes are Malaysia wall-clock; production MySQL's `NOW()` is UTC. The
  instructions tell Claude this, and `run_query` returns `now_malaysia`.

## Setup

**Production (once):**
1. `.env`: add the five `PETAV3_READ_DB_*` values (the SELECT-only account), then deploy. The
   deploy runs the two migrations below and `config:cache`.
2. Create the OAuth signing keys, as the app user, and let the web user read them:
   `php artisan passport:keys`, then
   `chown ubuntu:www-data storage/oauth-*.key && chmod 660 storage/oauth-*.key`.
   Passport refuses key files that are more open than 600/660. The keys live in `storage/`,
   never in git; regenerating them logs every connected Claude out.
3. Manage → People → Roles → tick **Claude Connector** for the people who should have it.

**Each staff member (1 minute):** claude.ai → Settings → Connectors → *Add custom connector* →
URL `https://<site>/mcp/sales` → Connect → sign in on the admin login → **Allow**. This works
on Pro, Max, Team and Enterprise plans.

## Related files

**Backend**
- `app/Mcp/Servers/PetaServer.php`: the server, tool list and Claude's instructions
- `app/Mcp/Tools/{ListTables,DescribeTable,RunQuery,SearchCode,ReadFile,ListFiles}.php`
- `app/Mcp/Support/ProductionDatabase.php`: DB reads + locks
- `app/Mcp/Support/SourceCode.php`: code reads + allow/deny lists
- `app/Http/Middleware/EnsureClaudeConnectorAccess.php`: the per-call permission gate (`claude.connector`)
- `app/Providers/AuthServiceProvider.php`: `registerPassport()` (token lifetimes, Allow screen)
- `app/Exceptions/Handler.php`: `/oauth/authorize` → `/manage/login`; `/mcp/*` → JSON 401
- `src/Auth/Permission.php`: `USE_CLAUDE_CONNECTOR`; `src/People/User.php`: `HasApiTokens` + `OAuthenticatable`
- `app/Http/Controllers/Manage/People/RolesController.php`: Roles page placement (AI & System)
- `config/mcp.php` (redirect allow-list), `config/auth.php` (`mcp` guard), `config/database.php` (`production_read`)

**Frontend**
- `resources/js/Pages/Auth/OAuthAuthorize.vue`: the Allow screen. It uses plain form posts, not
  Inertia visits, because approving redirects to claude.ai.

**Migrations**
- `2026_09_23_100001_create_oauth_tables.php`: Passport's five `oauth_*` tables
- `2026_09_23_100002_add_claude_connector_permission.php`: the permission row (granted to nobody)

**Seeders**
- `database/seeds/RolesSeeder.php`: Super Admin gets it; the legacy `admin` role is excluded

**Routes**
- `routes/ai.php`: `Mcp::oauthRoutes()` (throttled 20/min) + `POST /mcp/sales` (`mcp.sales`)
- Passport's own `/oauth/authorize`, `/oauth/token`, … (registered by the package)

**Tests**
- `tests/Feature/Mcp/ClaudeConnectorToolsTest.php`: tools, row cap, write refusal, the
  read-only session, code allow-list
- `tests/Feature/Mcp/ClaudeConnectorAuthTest.php`: discovery, redirect allow-list, Allow
  screen, the full sign-in to a working token, permission revocation
