# PetaV3 Connector — Claude and ChatGPT (MCP)

## What it does

Lets a staff member connect **Claude or ChatGPT** 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. The client registers itself: `POST /oauth/register` (dynamic client registration). Only
     `https://claude.ai` / `https://claude.com` / `https://chatgpt.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.
- **ChatGPT authentication compatibility.** `App\Mcp\Auth\McpOAuth` extends the package's
  registrar without modifying `vendor/`. Both authorization-server discovery paths explicitly
  advertise `token_endpoint_auth_methods_supported: ["none"]`: these are public clients using
  PKCE (Proof Key for Code Exchange), not anonymous users. Dynamic Client Registration (DCR)
  remains the registration method; Client ID Metadata Documents (CIMD) are not advertised.
  The exact ChatGPT callback comes from its connection settings; both the stable callback and
  `/connector/oauth/{callback_id}` are supported by the `https://chatgpt.com` domain allow-list.
- **Resource and scope binding.** `/oauth/authorize` and `/oauth/token` reject a supplied
  `resource` unless it is exactly the discovered `/mcp/sales` URL. Omission remains supported
  for older Claude clients: the sole `mcp:use` resource is inferred. Every newly issued or
  refreshed `mcp:use` token contains our issuer and resource audience. The client identifier
  remains the first audience for Passport compatibility. After Passport verifies signature,
  expiry and revocation, `EnsureMcpTokenAudience` checks issuer, resource audience and scope;
  a browser cookie alone cannot grant connector access. Non-MCP token issuance is unchanged.
- **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.

**ChatGPT (current official interface):**
1. Settings → Security and login → enable **Developer mode**, if the account/workspace permits it.
2. Open **Plugins**, click **+**, name the connection **PetaV3**, and enter
   `https://propertylabglobal.com/mcp/sales`.
3. Use **OAuth** with **Dynamic Client Registration**. No client secret is needed; the
   server registers a public client and requires PKCE. Do not select CIMD for this server.
4. Sign in with the usual PetaV3 staff account and click **Allow**. The existing **Claude
   Connector** role permission covers both clients; the stored permission name is unchanged.
5. Install/select the personal plugin, start a Work conversation and invoke `@PetaV3`.
   Try “How many new leads arrived this month?” and “Explain what /manage/memberships shows.”

References: [OpenAI connection guide](https://developers.openai.com/plugins/deploy/connect-chatgpt),
[OAuth requirements](https://developers.openai.com/plugins/build/auth).

**Deploying this compatibility update:** deploy the changed application files, then refresh
the normal Laravel configuration and route caches. No new database migration, environment
variable, database credential or signing-key rotation is required. Existing Claude access
tokens lack the new resource binding and will receive `401`; refresh obtains a bound token,
or the user can reconnect once if the client does not refresh after that response. Keep the
existing signing keys so refresh tokens remain usable. The database access remains unscoped
and read-only, as described above; adding ChatGPT does not add row-level visibility filters.

## 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/Http/Middleware/EnsureMcpTokenAudience.php`: bearer-token issuer, audience and scope checks
- `app/Http/Middleware/ValidateMcpOAuthResource.php`: authorization/token resource validation
- `app/Mcp/Auth/{McpOAuth,AccessToken}.php`: discovery metadata and resource-bound token issuance
- `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, full Claude/ChatGPT PKCE sign-in, tools/list, tool calls, token refresh, invalid
  resource/scope/audience/issuer, revoked tokens and permission revocation. OAuth tests use
  separate keys in `storage/framework/testing/mcp-oauth`, never the deployment's signing keys.
