# NUMU — MCP / Admin API Documentation

Base URL (prod): `https://dashboard.numuangels.net`
All AI/MCP routes live under `/api/v1/...` and only register when `config('ai.enabled')` is true.

---

## 1. Authentication

Two mechanisms, unified by the `McpAuth` middleware (`app/Http/Middleware/McpAuth.php`):

### A) OAuth 2.1 (recommended for MCP clients — tokens prefixed `naat_`)
Authorization-code + PKCE (S256 mandatory) + refresh-token rotation. JSON discovery + RFC 9728 protected-resource metadata.

| Endpoint | Method | Purpose |
|---|---|---|
| `/.well-known/oauth-authorization-server` | GET | AS metadata (endpoints, `code_challenge_methods_supported:["S256"]`, scopes) |
| `/.well-known/oauth-protected-resource[/{read\|full}]` | GET | Resource metadata; `resource` = audience the token must bind to |
| `/oauth/authorize` | GET | Consent screen (admin login) |
| `/oauth/authorize/decide` | GET/POST | Grant/deny → returns `code` (`nac_…`, 60s TTL, single-use) |
| `/oauth/token` | POST | Exchange code (or refresh) → opaque `naat_…` access token + refresh token |
| `/oauth/revoke` | POST | Revoke token / family |
| `/oauth/introspect` | POST | Token status |
| `/oauth/userinfo` | GET | Admin identity (Bearer) |

**Flow:**
1. `GET /.well-known/oauth-protected-resource/full` → read `authorization_servers` + `resource`.
2. `GET /.well-known/oauth-authorization-server` → `authorization_endpoint`, `token_endpoint`.
3. Build PKCE `code_verifier`/`code_challenge`; send the admin to `/oauth/authorize?response_type=code&client_id=...&redirect_uri=...&scope=numu:read numu:write&code_challenge=...&code_challenge_method=S256&resource=<resource>`.
4. After consent → `code`. `POST /oauth/token` `grant_type=authorization_code&code=...&code_verifier=...&redirect_uri=...&resource=<resource>` → `{access_token, refresh_token, token_type:"Bearer", expires_in, scope}`.
5. Call MCP with `Authorization: Bearer naat_...`. Refresh via `grant_type=refresh_token` (rotation; reuse → family revoke).

### B) Sanctum personal-access tokens (operator / stdio bridge)
Minted at `/admin/ai/connectors` (permission `ai.connectors.manage`). Abilities `numu:read` (read tier) or `numu:full` (full tier → expands to read+write+enrich+admin).

### Scopes & hierarchy
`numu:admin ⊇ numu:enrich ⊇ numu:write ⊇ numu:read`. Read tools need `numu:read`; writes need `numu:write`; enrichment needs `numu:enrich`; proposal approve/reject needs `numu:admin`.

### Required header
`Authorization: Bearer <token>` (header only — tokens in query string are rejected 400).
On 401: `WWW-Authenticate: Bearer realm="Numu", error=..., resource_metadata=<url>`.

---

## 2. MCP transport (JSON-RPC)

Two tier URLs (both `POST` JSON-RPC 2.0, `throttle:600,1`, `ai.cb` circuit-breaker):

| URL | Tier | Tools |
|---|---|---|
| `/api/v1/ai/mcp/read` | read (`numu:read`) | read tools only |
| `/api/v1/ai/mcp/full` | full (`numu:write`) | read + write tools |

Standard MCP methods: `initialize`, `tools/list`, `tools/call`. The tool catalog is defined in `app/Services/Ai/Mcp/McpToolRegistry.php` (canonical source — **40 tools**). Each tool call builds a sub-request that runs through the SAME REST controller + audit path as a direct API call.

`tools/call` example:
```json
POST /api/v1/ai/mcp/full
Authorization: Bearer naat_...
{ "jsonrpc":"2.0", "id":1, "method":"tools/call",
  "params": { "name":"investor_update",
    "arguments": { "id": 42, "changes": { "action_option_id": 17 }, "rationale": "qualified" } } }
```

---

## 3. REST endpoints (also callable directly)

> Auth column: **R** = `McpAuth:read` (`numu:read`); **W** = `McpAuth:full` (`numu:write`). Source files in the existing-tools map.

### Investors
| Method | URL | Auth | Body / Params | Returns |
|---|---|---|---|---|
| GET | `/api/v1/investors` | R | ~45 dashboard filters (q, group/category/action by name, ranges, tags, `*_option_id`, sort, page) | `InvestorResource[]` + meta (applied/unsupported filters) |
| GET | `/api/v1/ai/investors/{id}` | R | — | full record + gaps[] + notes + tags + files |
| GET | `/api/v1/ai/investors/search` | R | q, limit | matches |
| GET | `/api/v1/ai/investors/{id}/gaps` | R | — | missing-field list |
| PATCH/PUT | `/api/v1/investors/{id}` | W | flat JSON, ~40-field whitelist incl `group_id`, `tags[]`, `action_option_id` (→workflow), `rejection_reason` | refreshed resource + meta{updated_fields, tags_synced, ignored_fields, audit_row_id} |
| POST | `/api/v1/ai/investors/{id}/upload-avatar` | W | image_url, confidence | avatar_path |
| POST | `/api/v1/ai/investors/{id}/notes` | W | body (≤5000) | created note |

### Startups
| Method | URL | Auth | Body / Params | Returns |
|---|---|---|---|---|
| GET | `/api/v1/startups` | R | dashboard filters (incl country/city/sector/stage by name, financial ranges) | `StartupResource[]` + meta |
| GET | `/api/v1/ai/startups/{id}` | R | — | full record + gaps + notes + files |
| GET | `/api/v1/ai/startups/search` | R | q, limit | matches |
| PATCH/PUT | `/api/v1/startups/{id}` | W | label + direct field whitelist (incl. **`status_option_id`**, `sector_option_id`, `action_option_id`→workflow); names OR ids | resource + meta |
| POST | `/api/v1/ai/startups/{id}/upload-logo` | W | image_url | logo file |

#### Startup **Status** field (explicit)

The pipeline status is the column **`status_option_id`** — a `label_key=status` option. Edit it via `PATCH /api/v1/startups/{id}` (or the `startup_update` MCP tool) inside `changes`:

```jsonc
// PATCH /api/v1/startups/475
{ "changes": { "status_option_id": 12 }, "rationale": "had the intro meeting" }
```

Pass the **option id** (look them up via `GET /api/v1/ai/label-options?label_key=status`). Current catalog ids → slugs: `17 new`, `11 under_review`, `10 meeting_requested`, `12 had_meeting`, `14 listed`, `13 rejected`, `16 featured`, `15 no_action` (ids are environment-specific).

A status change fires the **same `StartupLabelOptionChanged` lifecycle event the dashboard fires**, so any notification armed on the target status option (Email/SMS/WhatsApp) is sent — it is **not** a raw column write. No-op safe (re-setting the same status sends nothing). Note: `status_option_id` is **distinct** from `action_option_id` (the Approve/Reject/Move-to-Committee workflow field — see `startup_set_action`) and from `group_id` (the pipeline group).

### Demos / Committees (existing)
| Method | URL | Auth | Body | Returns |
|---|---|---|---|---|
| GET | `/api/v1/ai/demos` · `/ai/demos/{id}` | R | status, cursor / id | list / record+members+notes |
| PUT | `/api/v1/demos/{id}/notes` | W | notes | updated |
| POST | `/api/v1/ai/demos/{id}/retry-notifications[/preview]` | W/R | channels, delivery_state, automation_type, audience | dispatched/summary |
| GET | `/api/v1/ai/committees` · `/{id}` | R | — | list / record |
| PUT | `/api/v1/committees/{id}/notes` | W | notes | updated |
| POST | `/api/v1/ai/committees/{id}/retry-notifications[/preview]` | W/R | filters | dispatched/summary |

### Meetings, Tags, Notes, Files, Logs, Tasks, Enrichment
| Method | URL | Auth | Notes |
|---|---|---|---|
| GET | `/api/v1/meetings`, `/meetings/{id}`, `/investors/{id}/meetings` | R | meeting resources |
| PUT | `/api/v1/meetings/{id}/note` | W | meeting_notes |
| PATCH | `/api/v1/meetings/{id}/attendance` | W | attendance_status (coming/attended/not_attended) |
| GET/POST | `/api/v1/ai/tags`, `/ai/tags/upsert`, `/ai/tags/{tag}/attach-investor` | R/W | tag CRUD-lite |
| GET/POST | `/api/v1/ai/notes` (list), `/ai/notes` (create) | R/W | entity notes |
| GET | `/api/v1/ai/files/{file}` | R | stream bytes (lazy Monday warm-up) |
| GET | `/api/v1/ai/activity-logs[/{id}]`, `/ai/notification-logs[/{id}]` | R | read-only audit |
| GET/POST | `/api/v1/ai/tasks[...]` | R/W | async tasks |
| (MCP) | `enrichment_run` / `enrichment_preview` | enrich | investor/startup enrichment (27/35 fields) |
| (MCP) | `enrichment_preview_proposals_{approve,reject,bulk_approve}` | admin | proposal review |

### Errors
| Status | Meaning |
|---|---|
| 400 | token in query string / malformed request |
| 401 | missing/invalid/expired token (with `WWW-Authenticate`) |
| 403 | insufficient scope for tool/tier |
| 404 | `ai.enabled=false`, or resource not found |
| 422 | validation failure (field rules) |
| 429 | throttle (600/min MCP) |

---

## 4. New endpoints added by this audit (gap-closure)

See `NUMU_MCP_GAP_ANALYSIS.md` for the full batched plan. Batch status is tracked in the gap-analysis coverage table.

### Batch 1 — Demo & Committee write parity ✅ (live)
All `McpAuth:full` (`numu:write`), audited + rollback-able. Each reuses the same FormRequest shape + service the dashboard uses.

| Method | URL | MCP tool | Body | Reuse |
|---|---|---|---|---|
| PATCH | `/api/v1/demos/{id}` | `demo_update` | any of: title_ar, title_en, type_option_id, mode_option_id, meeting_date (YYYY-MM-DD), meeting_time (HH:MM), meeting_url, rationale | `DemoController::update` shape; `month_taken` 422 on month clash |
| PATCH | `/api/v1/demos/{id}/investors/{investor}/attendance` | `demo_set_investor_attendance` | admin_status ∈ {pending_verification, attended, did_not_attend} | `DemoAttendanceService::recordAdminTransition` + effective-audience guard |
| PATCH | `/api/v1/demos/{id}/startups/{startup}/attendance` | `demo_set_startup_attendance` | status ∈ {pending, attended, did_not_attend} | roster guard + `DemoStartupAttendance` |
| PATCH | `/api/v1/committees/{id}` | `committee_update` | meeting_date, meeting_time, meeting_url, rationale | `CommitteeController::update` shape |
| PATCH | `/api/v1/committees/{id}/investors/{investor}/attendance` | `committee_set_investor_attendance` | admin_status ∈ {pending_verification, attended, did_not_attend} | `CommitteeAttendanceService::recordAdminTransition` |
| PATCH | `/api/v1/committees/{id}/startups/{startup}/attendance` | `committee_set_startup_attendance` | status ∈ {pending, attended, did_not_attend} | roster guard + `CommitteeStartupAttendance` |

Response: `{ data: { changed: bool, ... }, meta: {...} }`. `changed:false` on a no-op (idempotent). Rollback via `/admin/ai/...` (action types `demo.update`, `committee.update`, `demo.investor_attendance_update`, `demo.startup_attendance_update`, `committee.investor_attendance_update`, `committee.startup_attendance_update`).

### Batch 2 — create + audience/members + evaluations ✅ (live)
| Method | URL | MCP tool | Reuse |
|---|---|---|---|
| POST | `/api/v1/demos` | `demo_create` | core-field validation; `month_taken` guard |
| POST | `/api/v1/committees` | `committee_create` | same |
| PUT | `/api/v1/demos/{id}/audience` | `demo_set_audience` | `demo_investors` + `demo_groups` sync; rollback-able |
| PUT | `/api/v1/committees/{id}/members` | `committee_set_members` | `committee_investors` sync; rollback-able |
| PUT | `/api/v1/committees/{id}/evaluations` | `committee_set_evaluations` | shared `CommitteeEvaluationService` (admin uses it too) |
| PUT | `/api/v1/demos/{id}/slots` | `demo_set_slots` | shared `SlotScheduleService` (admin form + controller use it too); rollback-able |
| PUT | `/api/v1/committees/{id}/slots` | `committee_set_slots` | same shared service |

**Demos & Committees: full UI→API write parity achieved.**

### Batch 3a — Startup team members ✅ (live)
| Method | URL | MCP tool | Reuse |
|---|---|---|---|
| POST | `/api/v1/startups/{id}/members` | `startup_member_create` | shared `StartupMemberService` (admin uses it too) |
| PATCH | `/api/v1/startups/{id}/members/{member}` | `startup_member_update` | same; rollback-able (scalar fields) |
| DELETE | `/api/v1/startups/{id}/members/{member}` | `startup_member_delete` | soft-delete |

Also fixed: `cap_table` DD document_type was offered in the UI but rejected by the upload validator — now whitelisted.

### Batch 3b — Startup files ✅ (live)
| Method | URL | MCP tool | Reuse / safety |
|---|---|---|---|
| POST | `/api/v1/startups/{id}/files` | `startup_file_upload` | shared `StartupFileService::store`; **upload-by-URL** (clients have no file handle) — `UrlGuard` SSRF check + per-slot size cap + content-based mime validation (same rules as the dashboard); single-file slots (logo/pitch_deck) auto-displace the prior file |
| PATCH | `/api/v1/startups/{id}/files/{file}/slot` | `startup_file_move` | `StartupFileService::move` (relocates blob, displaces occupant); **rollback-able** (moves back) |
| DELETE | `/api/v1/startups/{id}/files/{file}` | `startup_file_delete` | `StartupFileService::delete` (blob purge + remove); not auto-reversible (blob gone) |

**Companies: full UI→API write parity achieved** (~100%). The dashboard's `fileUploadRules`/`persistFile`/`moveFileSlot`/`destroyFile`/`purgeFileBlob` were all re-pointed at `StartupFileService` — one implementation for UI + API.

### Batch 4 — Pivots, favorite, note/tag edit ✅ (live)
| Method | URL | MCP tool | Reuse / policy |
|---|---|---|---|
| PUT | `/api/v1/investors/{id}/preferences` | `investor_set_preferences` | shared `EditsEntitySections::syncPivot` (sectors/stages/payment_methods); rollback-able |
| PUT | `/api/v1/startups/{id}/geographic-focus` | `startup_set_geographic_focus` | same trait; **also fixed a latent admin bug** — the denormalized `geographic_focus_name` now auto-fills (`StartupLocation` model hook) |
| POST | `/api/v1/favorites/toggle` | `favorite_toggle` | flips `is_favorite` (investor/startup); `desired` for idempotent set; rollback-able |
| PATCH | `/api/v1/ai/notes/{note}` | `note_update` | edits a note body; rollback-able. **Notes are never deleted via API** (owner decision — audit-trail integrity) |
| PATCH | `/api/v1/ai/tags/{tag}` | `tag_update` | renames/recolors a tag (unique name); rollback-able. **Tags are never deleted/detached via API** (owner decision — a rename/delete fans out to every entity) |

**Investors: write parity achieved** (~100%, with the two documented owner exceptions above).

### Intentional non-goals (owner decisions, 2026-06-25)
- Notes: create + edit only — **no delete**.
- Tags: add + rename/recolor only — **no delete, no detach, no full-sync**.
- Entity DELETE (investor/startup/demo/committee): not exposed.
- `startup_file_upload` / `startup_file_delete`: audited but not auto-reversible (disk blob); `startup_file_move` is reversible.
