feat: add remote purchasing MCP to existing app

This commit is contained in:
DEls 2026-10-05 02:46:36 +02:00
parent 9f0698f1d1
commit 4922d649ae
37 changed files with 3222 additions and 144 deletions

View file

@ -16,7 +16,9 @@ dist
# Local config & secrets
.env
.env.local
.env.*.local
.env*
!.env.example
!docs/setup/mega-mcp.env.example
.vscode
.idea
.DS_Store
@ -49,3 +51,14 @@ public/workbox-*.js
# Generated by Next.js at build time
next-env.d.ts
# Local credentials and isolated agent workspaces
cooler.env
kilo.json
.mcp.remote.local.json
.claude
.codex
.agents
.superpowers
.worktrees
worktrees

5
.gitignore vendored
View file

@ -1,4 +1,4 @@
node_modules/
node_modules
.next/
out/
build/
@ -36,3 +36,6 @@ __pycache__/
.claude/worktrees/
.claude/settings.local.json
.codex/config.toml
# Local MCP client credentials
.mcp.remote.local.json

View file

@ -93,9 +93,11 @@ redirect to the pages above and keep `?site=`.
| [`docs/pwa-install-verification.md`](./docs/pwa-install-verification.md) | Checking the dashboard / clock PWAs install correctly on iOS + Android |
| [`docs/n8n/`](./docs/n8n/) | The n8n PO-ingest and DN-OCR workflows |
| [`docs/setup/env-and-mcp.md`](./docs/setup/env-and-mcp.md) | Local env + MCP client setup |
| [`docs/setup/api-and-mcp-connections.md`](./docs/setup/api-and-mcp-connections.md) | Tested API/MCP settings, credential locations, examples, live results and route inventory |
The `docs/superpowers/` directory has older design notes; treat as
historical, not authoritative.
The current remote purchasing MCP design and implementation plan are in
`docs/superpowers/specs/` and `docs/superpowers/plans/`; for operational
setup and deployment status, use the linked setup guide above.
---
@ -164,3 +166,7 @@ financial roll-up.
## License
Proprietary. Internal use only.
## Remote purchasing MCP
See [remote setup and deployment](docs/setup/remote-purchasing-mcp.md) for the same-app `/api/mcp` endpoint, dedicated keys, purchasing writes and no-delete policy.

View file

@ -0,0 +1,232 @@
# MEGA INFRASTRUCTURE — API and MCP connection guide
Historical connection audit, **5 October 2026**, Africa/Johannesburg. This guide describes this checkout and the live checks saved alongside it.
**Remote MCP update:** See [the remote purchasing MCP guide](./remote-purchasing-mcp.md) for the implemented same-app endpoint, dedicated credentials, client settings and deployment status. The audit below describes the earlier local-only transport and preserves its verified API findings.
**Yes: the app has an HTTP API and a working read-only MCP server.** MCP runs as a local process and reads your hosted PocketBase database using existing credentials. Its current transport is **stdio**: there is no MEGA MCP URL, port, API key or OAuth login.
## Addresses and access
| Connection | Address / setting | Authentication |
| --- | --- | --- |
| Production app API | `https://mega.dash.apphub.co.za/api` | Login session or route-specific secret |
| Local app API | `http://localhost:6004/api` | Currently PIN fallback; ordinary routes do not require a session |
| PocketBase REST API | `https://pb.mega.infra.apphub.co.za/api` | PocketBase token in `Authorization` |
| MEGA MCP | `mega-infrastructure`, local stdio | PocketBase credentials from env file |
| MEGA MCP permissions | 27 read-only tools | Superuser database reads across all sites |
| n8n MCP, separate service | `https://wfdash.n8n.apphub.co.za/mcp-server/http` | Separate n8n MCP credential; not tested here |
`npm run dev` uses **6004**; `npm run start` uses **3000**. Production exposes HTTPS through the reverse proxy.
There is **no universal MEGA API key** or general API-key issuance route in this checkout. `MINIMAX_API_KEY` and `ANTHROPIC_API_KEY` are outbound AI-provider credentials. They are not needed to use the read-only MCP tools.
## Copy-ready MCP settings
These paths are correct for this computer. The repository and KIOXIA volume must be mounted when the client starts the server. Verification used Node **22.22.3** and the installed dependencies. Change the paths when using another computer.
| Field | Value |
| --- | --- |
| Name | `mega-infrastructure` |
| Transport | `stdio` |
| Command | `/Volumes/KIOXIA/.hermes/node/bin/node` |
| Arguments, in order | `--import`, `tsx`, `--env-file=/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/.env.local`, `/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/src/mcp/server.ts` |
| Working directory | `/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE` |
| Startup timeout | 20 seconds |
| Tool timeout | 60 seconds |
| Extra env variables | None; `.env.local` supplies credentials |
| Server URL / MCP API key | Leave blank |
### Codex TOML
Copy [mega-mcp.codex.toml](./mega-mcp.codex.toml) into your existing MCP configuration, preserving other servers:
```toml
[mcp_servers.mega-infrastructure]
command = "/Volumes/KIOXIA/.hermes/node/bin/node"
args = ["--import", "tsx", "--env-file=/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/.env.local", "/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/src/mcp/server.ts"]
cwd = "/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE"
startup_timeout_sec = 20
tool_timeout_sec = 60
```
Codex supports these fields in `~/.codex/config.toml` or a project `.codex/config.toml`. Its MCP settings UI supports choosing STDIO and entering the command. Save and restart the MCP connection after adding it. See [official MCP configuration documentation](https://learn.chatgpt.com/docs/extend/mcp?surface=cli).
### MCP JSON clients
[mega-mcp.json](./mega-mcp.json) contains the same command, arguments and working directory in `mcpServers` format, with no secret values. Client configuration filenames and support for `cwd` vary; the table above supplies each field individually.
The tested launch uses Node directly, keeping npm's script banner out of the protocol stream. The repository also provides `npm run mcp` for manual startup. `--import tsx` loads TypeScript without the tsx CLI's extra IPC listener.
After connecting, ask your client to **list sites**, **show purchase orders**, or **show pending verification**. It should discover 27 tools. The Next.js app does not need to be running for MCP; PocketBase must be reachable. Client configuration was prepared, but your personal MCP settings were not edited.
## Credentials: where each actual value lives
The values already exist in the linked, ignored local files. Commands below load them directly, so you do not need to copy secrets into a shareable document.
| Value | Location | Purpose and verification |
| --- | --- | --- |
| `NEXT_PUBLIC_POCKETBASE_URL` | [.env.local](../../.env.local), [cooler.env](../../cooler.env) | Hosted database URL; reachable |
| `POCKETBASE_ADMIN_EMAIL`, `POCKETBASE_ADMIN_PASSWORD` | Both files above | Server/MCP database login; deployment-file credentials worked in live MCP calls |
| `POCKETBASE_ADMIN_TOKEN` | `.env.local` | Preset superuser token; authenticated PocketBase GET passed |
| Production `N8N_INGEST_SECRET` | **`cooler.env`** | Header `x-ingest-secret`; production PO lookup passed |
| Local `N8N_INGEST_SECRET` | `.env.local` | **Still the literal template placeholder.** Local accepts it; production rejected it with 401. Use `cooler.env` for production integrations |
| App user email/password | Your existing named MEGA account | Login returns a `mega_session` cookie; successful named-user login was not live-tested |
| `RECEIPT_CORRECTION_SECRET` | `.env.local`; production value from deployment settings | Extra correction code for posted receipts, not a general API key; production value not verified |
| n8n MCP bearer token | Your existing n8n MCP credential/client settings | Separate from PocketBase and ingest credentials; not tested or copied here |
| AI provider keys | Server/provider credential settings | Outbound AI calls only |
The current PocketBase adapter requires **both email and password even when a preset token exists**. It uses `POCKETBASE_ADMIN_TOKEN` preferentially, otherwise logs in with email/password. A stale preset token is not automatically replaced by password login. See [src/lib/pb.ts](../../src/lib/pb.ts).
`cooler.env` is a deployment reference; `npm run dev` does not automatically load it. `.env.coolify` is a placeholder template. `.env.coolify.local` also retains the placeholder ingest secret. No credentials or deployment settings were changed during this audit.
## API examples
### Public health
```bash
curl --fail-with-body https://mega.dash.apphub.co.za/api/health
```
Expected: HTTP 200 with `status: "ok"`, `uptime`, `timestamp`. Health alone does not prove database access.
### Named-user app session
With `AUTH_MODE=users`, protected app routes read the **`mega_session` cookie**, not an `Authorization: Bearer ...` API key. Use a MEGA user login, not a PocketBase superuser login:
```http
POST https://mega.dash.apphub.co.za/api/auth/login
Content-Type: application/json
{"email":"YOUR_MEGA_USER_EMAIL","password":"YOUR_MEGA_USER_PASSWORD"}
```
A successful response includes `user`, `landing`, and `Set-Cookie: mega_session=...`. Keep the cookie in your API client's cookie jar, then call:
```http
GET https://mega.dash.apphub.co.za/api/dashboard-attention
Cookie: mega_session=YOUR_SESSION_TOKEN
```
The cookie is HttpOnly, SameSite=Lax, Secure in production, with a five-day browser lifetime. PocketBase checks token validity. Password-change requirements, roles and site assignments can deny requests; non-admin users are scoped to assigned sites, with additional route-specific checks.
Local PIN mode does **not** authenticate ordinary API requests: `getCurrentUser()` supplies the PIN admin identity without a cookie. The local dashboard GET returned 200 without credentials. Production returned 401 without a session. The browser PIN is not an API key.
### Production machine API
Run this from the repository directory. It loads the **tested production secret** from `cooler.env`; replace `YOUR_PO_NUMBER`:
```bash
node --env-file=cooler.env --input-type=module - <<'NODE'
const poNumber = "YOUR_PO_NUMBER";
const response = await fetch(
`${process.env.APP_URL}/api/purchase-orders/by-number/${encodeURIComponent(poNumber)}/items`,
{ headers: { "x-ingest-secret": process.env.N8N_INGEST_SECRET } },
);
console.log("HTTP", response.status);
console.log(await response.json());
NODE
```
Expected: 200 and `{ po, items }`; unmatched POs may return `po: null`. Missing/wrong secrets return 401. The live check looked up a real PO without retaining its contents in the report.
| Method | Route using `x-ingest-secret` | Purpose |
| --- | --- | --- |
| GET | `/api/purchase-orders/by-number/[poNumber]/items` | Read PO lines |
| GET | `/api/purchase-orders/[id]/delivery-reconciliation` | Read reconciliation snapshot |
| GET | `/api/delivery-notes/[id]/slip` | Read source slip |
| POST | `/api/purchase-orders/ingest` | Ingest PO data; changes records |
| POST | `/api/purchase-orders/process-batch` | Finalise PO extraction; changes records |
| POST | `/api/dn-batches/tick` | Process queued delivery documents; changes records and may call AI |
The ingest secret only authorizes handlers that check it. It does not replace the login cookie on ordinary routes. Receipt corrections additionally require operational authorization, the correction code and same-origin checks. Some Ask/action routes check Origin; `x-ask-user` is an Ask identity selector, not a general API key. General cross-origin browser API access is not configured in this checkout; use a server-side client or the app's own origin.
### Direct PocketBase REST API
PocketBase is a separate service. Send its **raw token** in `Authorization`, rather than the app cookie. See [PocketBase authentication documentation](https://pocketbase.io/docs/authentication/).
```bash
node --env-file=.env.local --input-type=module - <<'NODE'
const response = await fetch(
`${process.env.NEXT_PUBLIC_POCKETBASE_URL}/api/collections/sites/records?page=1&perPage=1&fields=id,name,project_code`,
{ headers: { Authorization: process.env.POCKETBASE_ADMIN_TOKEN } },
);
console.log("HTTP", response.status);
console.log(await response.json());
NODE
```
Superuser database access bypasses app business checks and site restrictions. The supplied MCP process is for the owner/admin; it does not authenticate individual app users. Ordinary PocketBase user-token access is subject to collection rules and was not verified here.
The [current route inventory](./api-route-inventory.md) lists **78 route files** with methods and links to source. The older [API reference](../api-reference.md) contains payload details, but some auth descriptions predate named-user login; current source and this guide take precedence.
## All 27 MCP tools
These were discovered through a live `tools/list`. Full descriptions and JSON input schemas are preserved in [connection-check-results.json](./connection-check-results.json).
| Tools | What they read |
| --- | --- |
| `list_sites`, `get_site_financials`, `get_site_trend` | Site totals and trends |
| `site_report`, `portfolio_report`, `problem_report`, `improvement_report` | Reports and findings |
| `vehicles_needing_attention`, `get_fuel_summary`, `get_vehicle_fuel_log` | Fleet and fuel |
| `get_pending_verification` | Unverified POs and delivery notes |
| `get_purchase_order`, `list_purchase_orders` | Purchase orders and lines |
| `get_delivery_note`, `list_delivery_notes`, `delivery_note_queue` | Delivery notes and queues |
| `top_workers`, `list_employees`, `attendance_today` | Workforce and attendance |
| `list_plant_hours`, `plant_equipment_summary` | Plant hours and summaries |
| `list_fixed_costs`, `list_income_claims`, `supplier_balances` | Costs, income and suppliers |
| `list_imports`, `rain_outlook`, `list_change_requests` | Imports, weather and logged requests |
Argument examples: `list_sites` takes `{}`; `list_employees` and `list_purchase_orders` accept `{ "limit": 1 }`; `get_site_financials` requires `{ "site": "SITE_NAME_OR_CODE_OR_ID" }`; `get_site_trend` additionally accepts `"granularity": "month"` or `"week"`.
Ask chat has separate action/write tools. [src/mcp/server.ts](../../src/mcp/server.ts) registers only the read-only tool set; Ask's actions are not exposed through this MCP server.
## Test results
| Check | Result |
| --- | --- |
| MCP initialize/discovery | Passed with both env files; v1.0.0, 27 tools |
| Live MCP reads | `list_sites`, `list_employees`, `list_purchase_orders`, `get_pending_verification` passed with both env files |
| PocketBase health and preset-token records GET | Both 200 |
| Local app health | 200 |
| Local dashboard GET without session | 200; PIN fallback |
| Production app health | 200 |
| Production dashboard GET without session | 401, `Sign in required` |
| PO lookup without secret, both hosts | Both 401 |
| Local PO lookup with `.env.local` secret | 200; placeholder accepted locally |
| Production PO lookup with `.env.local` secret | **401: credential mismatch** |
| Production PO lookup with `cooler.env` secret | **200: working production credentials** |
| Local `/api/mcp` GET | 404; no HTTP MCP route in this checkout |
| Production `/api/mcp` GET | 401 from login guard; does not establish an HTTP MCP endpoint |
| Focused automated tests | **58 passed in 7 files**, covering auth/login, route classification, health, batch tick, agent loads and read-only enforcement |
The [final local report](./local-connection-check-results.json) has **13 passed / 0 failed**. The [combined default-env report](./connection-check-results.json) has **17 passed / 1 failed**: the placeholder secret fails against production. The [deployment-env production report](./production-connection-check-results.json) has **12 passed / 0 failed**. These are connection checks, not exhaustive tests of every tool and operation.
Successful named-user login, data-changing API operations, n8n MCP authentication and AI-provider calls were not live-tested. Automatic approval review rejected the initial batch-processing endpoint probe because it could mutate queued documents. The final checker uses GET-only app requests and read-only MCP tools; the batch handler was covered by automated tests.
Reports contain schemas, status codes and response shapes, without secrets or business records. UTC timestamps on 4 October at about 22:25–22:27 correspond to **5 October** after midnight in Johannesburg.
## Repeat verification
The [saved checker](../../scripts/check-connections.mjs) loads an env file and prints secret-redacted JSON. App requests are GET-only and MCP tools read-only. With password-only PocketBase credentials, database authentication itself uses the normal login request.
```bash
# Local only, with the app running on 6004.
node scripts/check-connections.mjs
# Production with verified credentials — passed 12/12.
MEGA_CONNECTION_ENV_FILE=cooler.env node scripts/check-connections.mjs https://mega.dash.apphub.co.za
# Compare local credentials against both hosts — exposes the mismatch.
node scripts/check-connections.mjs http://127.0.0.1:6004 https://mega.dash.apphub.co.za
```
Repeat the focused automated tests with:
```bash
npm run test:run -- src/lib/agent/read-only.test.ts src/lib/agent/load.test.ts src/lib/auth/guard.test.ts src/lib/auth/proxy-path.test.ts src/app/api/health/route.test.ts src/app/api/auth/login/route.test.ts src/app/api/dn-batches/tick/route.test.ts
```
Failures produce a nonzero exit code. Run from a terminal with local socket/external network access. The checker changes neither env files nor deployed credentials.
For a URL-based MCP client on another device, the current server would need an authenticated HTTP transport or a supported remote-execution arrangement. This audit did not add one. Local MCP already reads the hosted production database.

View file

@ -0,0 +1,90 @@
# API route inventory
Generated from this checkout on 5 October 2026 (Africa/Johannesburg).
`[id]`, `[poNumber]`, etc. are path parameters. `[...filename]` is a catch-all. The proxy category below is from `src/lib/auth/proxy-path.ts` for `AUTH_MODE=users`; route handlers can require additional credentials, site permissions, or an Origin header. PIN mode skips the proxy login check. A `machine` category means the proxy defers authentication to the handler, usually `x-ingest-secret`.
See [the connection guide](./api-and-mcp-connections.md) for authentication and tested examples. This inventory replaces assumptions based on older route documentation.
| Methods | Route | Proxy category | Source |
| --- | --- | --- | --- |
| PATCH, DELETE | `/api/admin/users/[id]` | admin | [route.ts](../../src/app/api/admin/users/[id]/route.ts) |
| GET, POST | `/api/admin/users` | manager | [route.ts](../../src/app/api/admin/users/route.ts) |
| POST | `/api/agent/actions/[id]/apply` | admin | [route.ts](../../src/app/api/agent/actions/[id]/apply/route.ts) |
| POST | `/api/agent/actions/[id]/reject` | admin | [route.ts](../../src/app/api/agent/actions/[id]/reject/route.ts) |
| GET | `/api/agent/actions/[id]` | admin | [route.ts](../../src/app/api/agent/actions/[id]/route.ts) |
| POST | `/api/agent/actions/[id]/undo` | admin | [route.ts](../../src/app/api/agent/actions/[id]/undo/route.ts) |
| PATCH | `/api/ask/change-requests/[id]` | admin | [route.ts](../../src/app/api/ask/change-requests/[id]/route.ts) |
| GET | `/api/ask/change-requests` | admin | [route.ts](../../src/app/api/ask/change-requests/route.ts) |
| DELETE | `/api/ask/memories/[id]` | admin | [route.ts](../../src/app/api/ask/memories/[id]/route.ts) |
| GET, POST | `/api/ask/memories` | admin | [route.ts](../../src/app/api/ask/memories/route.ts) |
| GET | `/api/ask/messages/[id]/pdf` | admin | [route.ts](../../src/app/api/ask/messages/[id]/pdf/route.ts) |
| GET | `/api/ask/users` | admin | [route.ts](../../src/app/api/ask/users/route.ts) |
| POST | `/api/auth/change-password` | public | [route.ts](../../src/app/api/auth/change-password/route.ts) |
| POST | `/api/auth/login` | public | [route.ts](../../src/app/api/auth/login/route.ts) |
| POST | `/api/auth/logout` | public | [route.ts](../../src/app/api/auth/logout/route.ts) |
| POST | `/api/chat/attachments` | admin | [route.ts](../../src/app/api/chat/attachments/route.ts) |
| POST | `/api/chat` | admin | [route.ts](../../src/app/api/chat/route.ts) |
| GET, PATCH, DELETE | `/api/chat/threads/[id]` | admin | [route.ts](../../src/app/api/chat/threads/[id]/route.ts) |
| GET, POST | `/api/chat/threads` | admin | [route.ts](../../src/app/api/chat/threads/route.ts) |
| GET | `/api/clock/employee-lookup` | clock | [route.ts](../../src/app/api/clock/employee-lookup/route.ts) |
| GET | `/api/clock/employee-search` | clock | [route.ts](../../src/app/api/clock/employee-search/route.ts) |
| POST | `/api/clock/events/[id]/correct` | clock | [route.ts](../../src/app/api/clock/events/[id]/correct/route.ts) |
| POST | `/api/clock/scan` | clock | [route.ts](../../src/app/api/clock/scan/route.ts) |
| POST | `/api/clock/vehicle-lookup` | clock | [route.ts](../../src/app/api/clock/vehicle-lookup/route.ts) |
| POST | `/api/clock/vehicle-scan` | clock | [route.ts](../../src/app/api/clock/vehicle-scan/route.ts) |
| GET | `/api/dashboard-attention` | dashboard | [route.ts](../../src/app/api/dashboard-attention/route.ts) |
| POST | `/api/data-import-batches/[id]/retry` | clock | [route.ts](../../src/app/api/data-import-batches/[id]/retry/route.ts) |
| GET | `/api/data-import-batches/[id]/status` | clock | [route.ts](../../src/app/api/data-import-batches/[id]/status/route.ts) |
| POST | `/api/delivery-notes/[id]/assign-po` | clock | [route.ts](../../src/app/api/delivery-notes/[id]/assign-po/route.ts) |
| POST | `/api/delivery-notes/[id]/ocr-result` | admin | [route.ts](../../src/app/api/delivery-notes/[id]/ocr-result/route.ts) |
| GET | `/api/delivery-notes/[id]/slip` | machine | [route.ts](../../src/app/api/delivery-notes/[id]/slip/route.ts) |
| POST | `/api/delivery-notes/upload` | clock | [route.ts](../../src/app/api/delivery-notes/upload/route.ts) |
| POST | `/api/dn-batches/[id]/assembly` | clock | [route.ts](../../src/app/api/dn-batches/[id]/assembly/route.ts) |
| POST | `/api/dn-batches/[id]/dismiss` | clock | [route.ts](../../src/app/api/dn-batches/[id]/dismiss/route.ts) |
| POST | `/api/dn-batches/[id]/notes/[noteId]/correct-receipt` | clock | [route.ts](../../src/app/api/dn-batches/[id]/notes/[noteId]/correct-receipt/route.ts) |
| POST | `/api/dn-batches/[id]/notes/[noteId]/dismiss` | clock | [route.ts](../../src/app/api/dn-batches/[id]/notes/[noteId]/dismiss/route.ts) |
| POST | `/api/dn-batches/[id]/notes/[noteId]/matching` | clock | [route.ts](../../src/app/api/dn-batches/[id]/notes/[noteId]/matching/route.ts) |
| POST | `/api/dn-batches/[id]/notes/[noteId]/post-line` | clock | [route.ts](../../src/app/api/dn-batches/[id]/notes/[noteId]/post-line/route.ts) |
| POST | `/api/dn-batches/[id]/notes/[noteId]/post` | clock | [route.ts](../../src/app/api/dn-batches/[id]/notes/[noteId]/post/route.ts) |
| GET, POST | `/api/dn-batches/[id]/notes/[noteId]/processing-preview` | clock | [route.ts](../../src/app/api/dn-batches/[id]/notes/[noteId]/processing-preview/route.ts) |
| POST | `/api/dn-batches/[id]/notes/[noteId]/resolve-entities` | clock | [route.ts](../../src/app/api/dn-batches/[id]/notes/[noteId]/resolve-entities/route.ts) |
| POST | `/api/dn-batches/[id]/notes/[noteId]/resolve-item` | clock | [route.ts](../../src/app/api/dn-batches/[id]/notes/[noteId]/resolve-item/route.ts) |
| PATCH | `/api/dn-batches/[id]/notes/[noteId]` | clock | [route.ts](../../src/app/api/dn-batches/[id]/notes/[noteId]/route.ts) |
| POST | `/api/dn-batches/[id]/pages/[n]/dismiss` | clock | [route.ts](../../src/app/api/dn-batches/[id]/pages/[n]/dismiss/route.ts) |
| GET | `/api/dn-batches/[id]/pages/[n]` | clock | [route.ts](../../src/app/api/dn-batches/[id]/pages/[n]/route.ts) |
| POST | `/api/dn-batches/[id]/rerun` | clock | [route.ts](../../src/app/api/dn-batches/[id]/rerun/route.ts) |
| POST | `/api/dn-batches/tick` | machine | [route.ts](../../src/app/api/dn-batches/tick/route.ts) |
| POST | `/api/fuel-logs/import` | clock | [route.ts](../../src/app/api/fuel-logs/import/route.ts) |
| GET | `/api/health` | public | [route.ts](../../src/app/api/health/route.ts) |
| GET | `/api/pb-files/[collection]/[recordId]/[...filename]` | clock | [route.ts](../../src/app/api/pb-files/[collection]/[recordId]/[...filename]/route.ts) |
| DELETE | `/api/plant-hours/[id]` | clock | [route.ts](../../src/app/api/plant-hours/[id]/route.ts) |
| GET | `/api/plant-hours/exception-report/[sheetId]` | clock | [route.ts](../../src/app/api/plant-hours/exception-report/[sheetId]/route.ts) |
| POST | `/api/plant-hours/import` | clock | [route.ts](../../src/app/api/plant-hours/import/route.ts) |
| GET | `/api/plant-hours/template` | clock | [route.ts](../../src/app/api/plant-hours/template/route.ts) |
| GET | `/api/purchase-orders/[id]/delivery-reconciliation` | machine | [route.ts](../../src/app/api/purchase-orders/[id]/delivery-reconciliation/route.ts) |
| GET | `/api/purchase-orders/[id]/document` | clock | [route.ts](../../src/app/api/purchase-orders/[id]/document/route.ts) |
| PATCH, DELETE | `/api/purchase-orders/[id]/items/[itemId]` | clock | [route.ts](../../src/app/api/purchase-orders/[id]/items/[itemId]/route.ts) |
| POST | `/api/purchase-orders/[id]/items` | clock | [route.ts](../../src/app/api/purchase-orders/[id]/items/route.ts) |
| GET | `/api/purchase-orders/[id]/pages/[n]` | clock | [route.ts](../../src/app/api/purchase-orders/[id]/pages/[n]/route.ts) |
| POST | `/api/purchase-orders/[id]/retry` | clock | [route.ts](../../src/app/api/purchase-orders/[id]/retry/route.ts) |
| GET | `/api/purchase-orders/[id]/status` | clock | [route.ts](../../src/app/api/purchase-orders/[id]/status/route.ts) |
| POST | `/api/purchase-orders/[id]/supplier` | clock | [route.ts](../../src/app/api/purchase-orders/[id]/supplier/route.ts) |
| GET | `/api/purchase-orders/by-number/[poNumber]/items` | machine | [route.ts](../../src/app/api/purchase-orders/by-number/[poNumber]/items/route.ts) |
| POST | `/api/purchase-orders/ingest` | machine | [route.ts](../../src/app/api/purchase-orders/ingest/route.ts) |
| POST | `/api/purchase-orders/process-batch` | machine | [route.ts](../../src/app/api/purchase-orders/process-batch/route.ts) |
| POST | `/api/purchase-orders/upload` | clock | [route.ts](../../src/app/api/purchase-orders/upload/route.ts) |
| POST | `/api/records/[collection]/[id]/verify` | admin | [route.ts](../../src/app/api/records/[collection]/[id]/verify/route.ts) |
| POST | `/api/sites/[id]/fixed-costs` | clock | [route.ts](../../src/app/api/sites/[id]/fixed-costs/route.ts) |
| PATCH | `/api/sites/[id]` | clock | [route.ts](../../src/app/api/sites/[id]/route.ts) |
| POST | `/api/sites` | clock | [route.ts](../../src/app/api/sites/route.ts) |
| PATCH | `/api/suppliers/[id]` | clock | [route.ts](../../src/app/api/suppliers/[id]/route.ts) |
| POST | `/api/suppliers` | clock | [route.ts](../../src/app/api/suppliers/route.ts) |
| POST | `/api/timesheets/import` | clock | [route.ts](../../src/app/api/timesheets/import/route.ts) |
| POST, DELETE | `/api/vehicles/[id]/attention-dismiss` | clock | [route.ts](../../src/app/api/vehicles/[id]/attention-dismiss/route.ts) |
| POST | `/api/vehicles/[id]/merge` | clock | [route.ts](../../src/app/api/vehicles/[id]/merge/route.ts) |
| PATCH, DELETE | `/api/vehicles/[id]` | clock | [route.ts](../../src/app/api/vehicles/[id]/route.ts) |
| POST | `/api/vehicles/import-summary` | clock | [route.ts](../../src/app/api/vehicles/import-summary/route.ts) |
| POST | `/api/vehicles` | clock | [route.ts](../../src/app/api/vehicles/route.ts) |
78 route files. Next.js may also supply HEAD/OPTIONS automatically; the table lists explicitly exported methods.

View file

@ -0,0 +1,762 @@
{
"checkedAt": "2026-10-04T22:25:50.524Z",
"timezone": "Africa/Johannesburg",
"localAuthMode": "pin",
"checks": [
{
"check": "MCP initialize",
"passed": true,
"server": {
"name": "mega-infrastructure",
"version": "1.0.0"
}
},
{
"check": "MCP tools/list",
"passed": true,
"toolCount": 27,
"tools": [
{
"name": "list_sites",
"description": "List every construction site with its contract value, total spent, percent burnt, projected final cost and projected profit. Use this for portfolio-wide questions like 'how are my sites doing' or 'which site is losing money'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "site_report",
"description": "Visual report for ONE site: KPI tiles, burn by category, weekly income vs expenses, daily fuel, top payroll, open POs and fuel attention, with charts. Use for '/site', 'report on X', 'show me X'. Accepts a site name, project code or id.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
}
},
"required": [
"site"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "portfolio_report",
"description": "Visual report across ALL sites: contract vs spent vs projected, burn vs time elapsed, and which sites are at risk. Use for '/portfolio' or 'how are all my sites doing'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "problem_report",
"description": "Problem report: everything wrong right now, ranked high/medium/low — sites heading for a loss or over plan, fuel alerts, fuel limits in the wrong unit, rejected hour-meter readings, failed imports, delivery notes stuck in the work queue, unverified and overdue purchase orders, missed scan-outs, sites with no income recorded. Each finding has a display-ready title, detail and dashboard link. Use for '/problems', 'what's wrong', 'what needs my attention'. Optionally one site.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id; omit for all sites"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "improvement_report",
"description": "Room-for-improvement report: setup and data-quality gaps that make the dashboard's figures less trustworthy — sites missing hours/week, start date, duration or map location; vehicles with no unit type or fuel limit; plant with no cost per hour; possible duplicate vehicles; workers with no rate or no site; test-looking entries; monthly overheads not yet logged this month. Each finding has a display-ready title, detail and link. Use for '/improvements', 'what should we clean up', 'how can the numbers be more accurate'. Optionally one site.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id; omit for all sites"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_site_financials",
"description": "Full financial breakdown for ONE site: contract value, spend split by overheads/payroll/materials/fuel, remaining budget, and the projected final cost and profit at the current run rate. Use for 'what is my projected profit on X' or 'where is the money going on X'. Accepts a site name, project code, or id.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code (e.g. C26/09), or record id"
}
},
"required": [
"site"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_site_trend",
"description": "Income versus expenses over time for one site, bucketed by month (default, last 6) or week (last 8). Use for 'how did spending trend on X' or 'was X profitable last month'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or record id"
},
"granularity": {
"type": "string",
"enum": [
"month",
"week"
],
"description": "Defaults to month"
}
},
"required": [
"site"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "vehicles_needing_attention",
"description": "Vehicles whose fuel consumption breaches their configured limit, with alert and warning counts. Alerts are more than 20% worse than the limit; warnings are any shortfall. Dismissed vehicles are excluded unless includeDismissed is true. Use for 'which vehicles need attention', 'which trucks are burning too much fuel'.",
"inputSchema": {
"type": "object",
"properties": {
"includeDismissed": {
"type": "boolean",
"description": "Include vehicles whose alert the user already acknowledged"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_fuel_summary",
"description": "Fleet-wide fuel totals: litres, spend at the current pump rate, number of vehicles, and how many fill-ups are flagged. Use for 'how much am I spending on fuel' or 'what is my total fuel usage'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_pending_verification",
"description": "Purchase orders and delivery notes still awaiting human verification. Use for 'what needs my approval', 'what is outstanding', 'what should I look at today'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_purchase_order",
"description": "Full detail for ONE purchase order including every line item: material code, description, quantity ordered, quantity still outstanding, unit, rate and amount. Use for 'what's on PO X', 'what did we order from supplier Y on that PO', 'what's still outstanding on PO X'.",
"inputSchema": {
"type": "object",
"properties": {
"poNumber": {
"type": "string",
"description": "Purchase order number, e.g. MEGCPO9199"
}
},
"required": [
"poNumber"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_purchase_orders",
"description": "List purchase orders, optionally filtered to one site and/or to verified status. Use for 'show me unverified POs', 'what POs are on the Hills'. For the contents of a specific PO use get_purchase_order.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"verified": {
"type": "boolean",
"description": "true for verified only, false for unverified only"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_delivery_note",
"description": "Full detail for ONE delivery note including every item received against it, the purchase order it was matched to, who signed for it, and the OCR confidence and processing status. Use for 'what came in on DN X', 'was delivery note X verified'.",
"inputSchema": {
"type": "object",
"properties": {
"deliveryNoteNumber": {
"type": "string",
"description": "Delivery note number"
}
},
"required": [
"deliveryNoteNumber"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_vehicle_fuel_log",
"description": "Fill-up history for ONE vehicle: date, litres, odometer reading, cost, and how each fill compared with the vehicle's configured consumption limit. Use for 'show me the Grader's fuel history', 'why is vehicle X flagged'. Accepts a description, QR code, or id.",
"inputSchema": {
"type": "object",
"properties": {
"vehicle": {
"type": "string",
"description": "Vehicle description, QR code, or record id"
},
"limit": {
"type": "number",
"description": "Most recent N fill-ups, default 20"
}
},
"required": [
"vehicle"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "top_workers",
"description": "Workers ranked by hours worked, with their labour cost. Optionally limited to one site. Use for 'who worked the most hours', 'top 10 workers', 'labour cost by person'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"limit": {
"type": "number",
"description": "How many to return, default 10"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_delivery_notes",
"description": "List delivery notes with their value, matched purchase order, date received and verification status. Sort by value to find the highest or lowest cost note. A note's value is the quantity received times the matched PO item rate; lines never matched to a PO count as zero. Use for 'list delivery notes', 'highest cost delivery note', 'what came in last month'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"verified": {
"type": "boolean",
"description": "true = verified only, false = unverified only"
},
"sortBy": {
"type": "string",
"enum": [
"value",
"dateReceived"
],
"description": "Defaults to dateReceived"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to desc"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_plant_hours",
"description": "Plant and equipment hour-meter entries: machine, dates, hours run, budgeted hours, cost per hour, value, and whether the entry went over budget. Use for 'which plant went over budget', 'how many hours did the excavator run', 'most expensive plant this month'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"overBudget": {
"type": "boolean",
"description": "true = over-budget entries only"
},
"sortBy": {
"type": "string",
"enum": [
"value",
"hours",
"startDate"
],
"description": "Defaults to startDate"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to desc"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_fixed_costs",
"description": "Individual preliminary-and-general (P&G) overhead entries for a site: description, cost, date and whether it recurs monthly or is a one-off. Use for 'what are my overheads on X', 'biggest fixed cost', 'what one-off costs did we have'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"recurring": {
"type": "boolean",
"description": "true = monthly recurring only, false = one-off only"
},
"sortBy": {
"type": "string",
"enum": [
"cost",
"date"
],
"description": "Defaults to date"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to desc"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_income_claims",
"description": "Payment claims raised against a contract: claim number, period, gross amount, who certified it, when it was paid, and whether it is draft, certified or paid. Use for 'what have we claimed', 'which claims are unpaid', 'biggest claim'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"status": {
"type": "string",
"enum": [
"draft",
"certified",
"paid"
],
"description": "Filter to one status"
},
"sortBy": {
"type": "string",
"enum": [
"amount",
"periodEnd"
],
"description": "Defaults to periodEnd"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to desc"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_employees",
"description": "The workforce roster: name, employee number, position, hourly rate, standard hours per day, and whether permanent or casual. Includes people with no hours logged, which top_workers does not. Use for 'who works on site X', 'what is someone's rate', 'how many casuals do we have'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"workerType": {
"type": "string",
"enum": [
"permanent",
"casual"
]
},
"sortBy": {
"type": "string",
"enum": [
"name",
"rate"
],
"description": "Defaults to name"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to asc for name, desc for rate"
},
"limit": {
"type": "number",
"description": "Defaults to 50"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_imports",
"description": "Document import batches — timesheets, fuel logs, purchase orders, delivery notes and plant hours — with their processing status, attempt count and any error. Use for 'what uploads failed', 'did my timesheet import work', 'what is stuck'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"status": {
"type": "string",
"enum": [
"pending",
"processing",
"processed",
"failed",
"dead"
],
"description": "Filter to one status"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "attendance_today",
"description": "Today's attendance from MEGA Clock scans, exactly as the On-site page shows it: who is on site now (and since when), who has clocked out, regulars from the last 7 days who have not arrived, and earlier-day clock-ins with no scan-out (needs attention). Counts plus names. Use for 'who is on site', 'attendance today', 'who hasn't arrived', 'missed scan-outs'. Optionally one site.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id; omit for all sites"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "supplier_balances",
"description": "What is owed to whom: per supplier, value ordered, value delivered and value still outstanding (computed live from PO lines and delivery notes, as on the Suppliers page), plus delivery notes still unresolved against them. Sorted by outstanding, highest first, with totals. Use for 'what do we owe', 'supplier balances', 'how much is outstanding with Afrisam'. Optionally filter by supplier name.",
"inputSchema": {
"type": "object",
"properties": {
"supplier": {
"type": "string",
"description": "Part of a supplier name; omit for all suppliers"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
},
"period": {
"type": "string",
"enum": [
"thisMonth",
"thisWeek",
"month1",
"month2",
"month3"
],
"description": "Only suppliers with goods RECEIVED in this period (by the delivery note's date received), with receivedInPeriod per supplier and in total — what is payable for that period, as on the Suppliers page. thisMonth = the current calendar month; month1/2/3 = one/two/three months back. Use for 'who do I need to pay this month'."
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "delivery_note_queue",
"description": "Where delivery-note paperwork stands: the inbox board's counts per column (reading photos, check grouping, identify document and PO, match delivery note to PO, ready to post, failed; completed documents have a separate page) and the work queue of documents that cannot be posted yet — how many lack a supplier, a purchase order or matched lines, grouped by reason, with examples. Use for 'what's in the DN inbox', 'what delivery notes are stuck', 'what needs matching'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "plant_equipment_summary",
"description": "Plant and equipment (hour-meter machines) from fuel logs: per item hours worked (guarded meter diffs — a reset or impossible reading counts as 0 h and is reported as rejected), value at its cost per hour, litres and L/h, with totals and charts. Use for 'how many hours did the plant run', 'what is my plant worth this contract', 'which machine uses the most fuel per hour', 'rejected meter readings', '/plant'. Optionally one site.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id; omit for all sites"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "rain_outlook",
"description": "7-day rain forecast per site (Open-Meteo, as on the Overview rain card): rain and chance per day, each day marked dry / watch / likely stop for earthworks, with the count of likely-stop days. Also names sites with no map location. Use for 'will it rain', 'weather this week', 'rain stops', '/today'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_change_requests",
"description": "Lists app change requests Ask has filed for the developer (new pages, columns, buttons or report layouts users asked for), optionally filtered by status. Read-only — status is changed from the Change Requests page, not here.",
"inputSchema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"open",
"planned",
"done",
"declined"
]
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
]
},
{
"check": "MCP tools/call list_sites",
"passed": true,
"shape": "array",
"rowCount": 3
},
{
"check": "MCP tools/call list_employees",
"passed": true,
"shape": "object",
"keys": [
"scope",
"total",
"showing",
"permanent",
"casual",
"employees"
]
},
{
"check": "MCP tools/call list_purchase_orders",
"passed": true,
"shape": "object",
"keys": [
"scope",
"total",
"showing",
"purchaseOrders"
]
},
{
"check": "MCP tools/call get_pending_verification",
"passed": true,
"shape": "object",
"keys": [
"purchaseOrdersPending",
"deliveryNotesPending",
"purchaseOrders",
"deliveryNotes"
]
},
{
"check": "PocketBase health",
"passed": true,
"status": 200
},
{
"check": "PocketBase authenticated records GET",
"passed": true,
"status": 200
},
{
"check": "http://127.0.0.1:6004: Public app health",
"passed": true,
"route": "/api/health",
"status": 200,
"contentType": "application/json",
"keys": [
"status",
"uptime",
"timestamp"
]
},
{
"check": "http://127.0.0.1:6004: 200 means no login was required; 401 means login is required",
"passed": true,
"route": "/api/dashboard-attention",
"status": 200,
"contentType": "application/json",
"keys": [
"plant",
"financial",
"onSite"
]
},
{
"check": "http://127.0.0.1:6004: Missing ingest secret must be rejected",
"passed": true,
"route": "/api/purchase-orders/by-number/[poNumber]/items",
"status": 401,
"contentType": "application/json",
"keys": [
"error"
],
"error": "unauthorized"
},
{
"check": "http://127.0.0.1:6004: Authenticated read-only PO lookup",
"passed": true,
"route": "/api/purchase-orders/by-number/[poNumber]/items",
"status": 200,
"contentType": "application/json",
"keys": [
"po",
"items"
]
},
{
"check": "http://127.0.0.1:6004: 401/404 is not an MCP handshake; no HTTP MCP route exists in this checkout",
"passed": true,
"route": "/api/mcp",
"status": 404,
"contentType": "text/html; charset=utf-8",
"keys": []
},
{
"check": "https://mega.dash.apphub.co.za: Public app health",
"passed": true,
"route": "/api/health",
"status": 200,
"contentType": "application/json",
"keys": [
"status",
"uptime",
"timestamp"
]
},
{
"check": "https://mega.dash.apphub.co.za: 200 means no login was required; 401 means login is required",
"passed": true,
"route": "/api/dashboard-attention",
"status": 401,
"contentType": "application/json",
"keys": [
"error"
],
"error": "Sign in required"
},
{
"check": "https://mega.dash.apphub.co.za: Missing ingest secret must be rejected",
"passed": true,
"route": "/api/purchase-orders/by-number/[poNumber]/items",
"status": 401,
"contentType": "application/json",
"keys": [
"error"
],
"error": "unauthorized"
},
{
"check": "https://mega.dash.apphub.co.za: Authenticated read-only PO lookup",
"passed": false,
"route": "/api/purchase-orders/by-number/[poNumber]/items",
"status": 401,
"contentType": "application/json",
"keys": [
"error"
],
"error": "unauthorized"
},
{
"check": "https://mega.dash.apphub.co.za: 401/404 is not an MCP handshake; no HTTP MCP route exists in this checkout",
"passed": true,
"route": "/api/mcp",
"status": 401,
"contentType": "application/json",
"keys": [
"error"
],
"error": "Sign in required"
}
],
"summary": {
"passed": 17,
"failed": 1
}
}

View file

@ -1,135 +1,37 @@
# Environment Variables & MCP Setup
# Environment variables and MCP setup
Single source of truth for everything Codex/Coolify needs to talk to
PocketBase, n8n, and the delivery-note OCR pipeline. If anything changes
here, update the three files together:
Use the [API and MCP connection guide](./api-and-mcp-connections.md) for tested app settings, credential locations, examples and results. Verified on 5 October 2026.
1. **`.env.local`** — your local dev secrets (NEVER committed)
2. **`.env.local.example`** — template with placeholders, committed to git
3. **`.env.coolify`** — production block to paste into Coolify's env UI, committed to git
## Environment files
## Local dev — `.env.local`
| File | Role |
| --- | --- |
| `.env.local` | Ignored local development/MCP credentials; loaded by Next.js development and `npm run mcp` |
| `.env.local.example` | Committed template; replace placeholders on another machine |
| `.env.coolify` | Committed production template containing placeholders |
| `.env.coolify.local` | Ignored deployment reference; not automatically loaded by the app |
| `cooler.env` | Ignored deployment reference containing the ingest secret verified against production |
This is what your `npm run dev` reads. The full current contents:
Both `.env.local` and `.env.coolify.local` retain the placeholder `N8N_INGEST_SECRET`. Production rejected it. The value in `cooler.env` passed an authenticated production read. No credentials were changed during verification.
```bash
# PIN gate — change this in production. Exposed to the client because the
# lock screen validates it locally before talking to PocketBase.
NEXT_PUBLIC_APP_PIN=2468
## Credentials and MCP
# Brand
NEXT_PUBLIC_APP_NAME=MEGA INFRASTRUCTURE
NEXT_PUBLIC_DEFAULT_THEME=light
- MEGA MCP: `NEXT_PUBLIC_POCKETBASE_URL`, `POCKETBASE_ADMIN_EMAIL`, `POCKETBASE_ADMIN_PASSWORD`; optional `POCKETBASE_ADMIN_TOKEN`. The adapter requires email/password even when a token exists.
- Named-user app API: MEGA login and its `mega_session` cookie in `AUTH_MODE=users`.
- Machine API routes: `x-ingest-secret` matching deployed `N8N_INGEST_SECRET`.
- Posted receipt correction: operational authorization, same-origin request and `RECEIPT_CORRECTION_SECRET` as the correction code.
- Outbound AI: provider-specific credentials; these are not MEGA API keys.
# Local API base URL (the Next.js app's own /api routes — currently a stub)
NEXT_PUBLIC_API_BASE_URL=http://localhost:6004/api
MEGA MCP uses **local stdio**, distinct from the external n8n MCP service at `N8N_MCP_URL`. The n8n bearer token is separate and was not tested here. Do not use the n8n HTTP settings for MEGA's stdio server.
# ── PocketBase ─────────────────────────────────────────────────────────────
NEXT_PUBLIC_POCKETBASE_URL=https://pb.mega.infra.apphub.co.za
POCKETBASE_ADMIN_EMAIL=admin@mega.co.za
POCKETBASE_ADMIN_PASSWORD=12345678
POCKETBASE_ADMIN_TOKEN=<your-PB-superuser-token>
Copy-ready settings: [Codex TOML](./mega-mcp.codex.toml), [MCP JSON](./mega-mcp.json). They contain paths, not secret values.
# ── n8n ───────────────────────────────────────────────────────────────────
N8N_INGEST_SECRET=<the-shared-secret-32-byte-hex>
N8N_MCP_URL=https://wfdash.n8n.apphub.co.za/mcp-server/http
N8N_PO_INGEST_URL=https://wfdash.n8n.apphub.co.za/webhook/purchase-order-upload
```
## Deployment and n8n
**To generate a fresh `N8N_INGEST_SECRET`:**
Use your deployment environment manager for actual production variables. Ignored local files do not automatically update Coolify or n8n. When changing an ingest secret, update the app and each calling workflow together; generating a new string alone does not connect a client.
```bash
openssl rand -hex 32
```
See [COOLIFY.md](../../COOLIFY.md) for deployment, [delivery-note workflow](../n8n/delivery-note-ocr.md) for batch tick integration, and [purchase-order workflow](../n8n/purchase-order-ingest.md) for PO ingestion.
The value MUST match on both sides — the Next.js server reads it via
`process.env.N8N_INGEST_SECRET`, and the n8n workflow reads it the same
way. If they drift, every webhook callback returns 401.
## Remote purchasing MCP
## Production — `.env.coolify`
Paste the entire contents of `.env.coolify` into Coolify → your
service → **Environment Variables**. The values are real (the same PB
URL, email, password, and admin token your local `.env.local` uses) so
the production server boots and authenticates immediately. Replace
`N8N_INGEST_SECRET=replace-with-output-of-openssl-rand-hex-32` with a
newly generated 32-byte hex string and put the **same value** in your
n8n workflow's env block.
## Codex / OpenCode MCP — n8n server
If you want Codex / OpenCode to talk to your n8n instance (for example,
to run workflows from the chat), add this MCP server block to your
local Codex/OpenCode config:
**For OpenCode** (`~/.config/opencode/opencode.json` or equivalent):
```json
{
"mcpServers": {
"n8n-mcp": {
"type": "http",
"url": "https://wfdash.n8n.apphub.co.za/mcp-server/http",
"headers": {
"Authorization": "Bearer <your-n8n-mcp-bearer-token>"
}
}
}
}
```
**For Codex Desktop** — paste the same JSON into *Settings → MCP Servers → Add*.
⚠️ The bearer token here is **different** from the PocketBase admin token
and from `N8N_INGEST_SECRET`. It's the n8n MCP auth token you were given
when you connected the n8n-mcp server. Rotate it independently.
## What I (Codex) currently see via the n8n MCP
Just the SDK reference doc (`n8n://workflow-sdk/reference`) — it gives
me the API for *writing* workflow code but **no tools to push to your
n8n instance, run workflows, or read execution logs remotely**. That's
why the n8n workflow had to be hand-written as a JSON file
(`docs/n8n/delivery-note-ocr-workflow.json`) and imported via the UI.
If you want me to have real n8n control (create/update workflows,
trigger runs, read executions), the MCP server at the URL above needs
to expose those tools. Ask the n8n MCP maintainer — it's a server-side
config issue, not something I can fix from here.
## Workflow import path (delivery-note OCR)
The workflow lives at `docs/n8n/delivery-note-ocr-workflow.json` (already
validated as parseable JSON). To import:
1. Open https://wfdash.n8n.apphub.co.za
2. **Workflows → ⋯ → Import from File** → pick that file
3. n8n creates the workflow with placeholder credential IDs
4. Create credentials in n8n (Settings → Credentials):
- **PocketBase Admin Token** (header auth, value = your PB superuser token)
- **OpenAI API Key** (header auth, value = `Bearer <your-openai-key>`)
5. Replace the placeholder IDs on the 5 nodes that reference them
6. Set workflow-level env vars on the workflow:
- `MEGA_PB_URL=https://pb.mega.infra.apphub.co.za`
- `MEGA_NEXT_URL=https://your-coolify-host.example.com`
- `N8N_INGEST_SECRET=<same value as your .env.local>`
7. Toggle **Active** to ON
Full details in `docs/n8n/delivery-note-ocr.md`.
## Quick reference: where each secret lives
| Secret | Where it's used | Where it's stored |
|---|---|---|
| `POCKETBASE_ADMIN_EMAIL` | Next.js server-side (PB auth) | `.env.local`, `.env.coolify` |
| `POCKETBASE_ADMIN_PASSWORD` | Next.js server-side (PB auth) | `.env.local`, `.env.coolify` |
| `POCKETBASE_ADMIN_TOKEN` | Next.js server-side (PB auth, fast-path) | `.env.local`, `.env.coolify` |
| `NEXT_PUBLIC_POCKETBASE_URL` | Both client and server (PB URL) | `.env.local`, `.env.coolify` |
| `N8N_INGEST_SECRET` | Next.js webhook receivers **and** n8n workflow headers | `.env.local`, `.env.coolify`, n8n workflow env |
| `N8N_PO_INGEST_URL` | Next.js PO upload route (forwards here) | `.env.local`, `.env.coolify` |
| `N8N_MCP_URL` | Your local Codex MCP config only | local Codex settings |
| n8n MCP bearer token | Your local Codex MCP config only | local Codex settings |
| OpenAI API key | n8n workflow credential (not in any .env) | n8n credentials store |
If a value lives in two places (e.g. `N8N_INGEST_SECRET` in both
Next.js and n8n), they MUST match — there's no auto-sync.
See [remote setup and deployment](./remote-purchasing-mcp.md) for the same-app `/api/mcp` endpoint, dedicated keys, purchasing writes and no-delete policy.

View file

@ -0,0 +1,707 @@
{
"checkedAt": "2026-10-04T22:34:04.539Z",
"timezone": "Africa/Johannesburg",
"envFile": ".env.local",
"localAuthMode": "pin",
"checks": [
{
"check": "MCP initialize",
"passed": true,
"server": {
"name": "mega-infrastructure",
"version": "1.0.0"
}
},
{
"check": "MCP tools/list",
"passed": true,
"toolCount": 27,
"tools": [
{
"name": "list_sites",
"description": "List every construction site with its contract value, total spent, percent burnt, projected final cost and projected profit. Use this for portfolio-wide questions like 'how are my sites doing' or 'which site is losing money'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "site_report",
"description": "Visual report for ONE site: KPI tiles, burn by category, weekly income vs expenses, daily fuel, top payroll, open POs and fuel attention, with charts. Use for '/site', 'report on X', 'show me X'. Accepts a site name, project code or id.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
}
},
"required": [
"site"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "portfolio_report",
"description": "Visual report across ALL sites: contract vs spent vs projected, burn vs time elapsed, and which sites are at risk. Use for '/portfolio' or 'how are all my sites doing'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "problem_report",
"description": "Problem report: everything wrong right now, ranked high/medium/low — sites heading for a loss or over plan, fuel alerts, fuel limits in the wrong unit, rejected hour-meter readings, failed imports, delivery notes stuck in the work queue, unverified and overdue purchase orders, missed scan-outs, sites with no income recorded. Each finding has a display-ready title, detail and dashboard link. Use for '/problems', 'what's wrong', 'what needs my attention'. Optionally one site.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id; omit for all sites"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "improvement_report",
"description": "Room-for-improvement report: setup and data-quality gaps that make the dashboard's figures less trustworthy — sites missing hours/week, start date, duration or map location; vehicles with no unit type or fuel limit; plant with no cost per hour; possible duplicate vehicles; workers with no rate or no site; test-looking entries; monthly overheads not yet logged this month. Each finding has a display-ready title, detail and link. Use for '/improvements', 'what should we clean up', 'how can the numbers be more accurate'. Optionally one site.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id; omit for all sites"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_site_financials",
"description": "Full financial breakdown for ONE site: contract value, spend split by overheads/payroll/materials/fuel, remaining budget, and the projected final cost and profit at the current run rate. Use for 'what is my projected profit on X' or 'where is the money going on X'. Accepts a site name, project code, or id.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code (e.g. C26/09), or record id"
}
},
"required": [
"site"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_site_trend",
"description": "Income versus expenses over time for one site, bucketed by month (default, last 6) or week (last 8). Use for 'how did spending trend on X' or 'was X profitable last month'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or record id"
},
"granularity": {
"type": "string",
"enum": [
"month",
"week"
],
"description": "Defaults to month"
}
},
"required": [
"site"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "vehicles_needing_attention",
"description": "Vehicles whose fuel consumption breaches their configured limit, with alert and warning counts. Alerts are more than 20% worse than the limit; warnings are any shortfall. Dismissed vehicles are excluded unless includeDismissed is true. Use for 'which vehicles need attention', 'which trucks are burning too much fuel'.",
"inputSchema": {
"type": "object",
"properties": {
"includeDismissed": {
"type": "boolean",
"description": "Include vehicles whose alert the user already acknowledged"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_fuel_summary",
"description": "Fleet-wide fuel totals: litres, spend at the current pump rate, number of vehicles, and how many fill-ups are flagged. Use for 'how much am I spending on fuel' or 'what is my total fuel usage'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_pending_verification",
"description": "Purchase orders and delivery notes still awaiting human verification. Use for 'what needs my approval', 'what is outstanding', 'what should I look at today'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_purchase_order",
"description": "Full detail for ONE purchase order including every line item: material code, description, quantity ordered, quantity still outstanding, unit, rate and amount. Use for 'what's on PO X', 'what did we order from supplier Y on that PO', 'what's still outstanding on PO X'.",
"inputSchema": {
"type": "object",
"properties": {
"poNumber": {
"type": "string",
"description": "Purchase order number, e.g. MEGCPO9199"
}
},
"required": [
"poNumber"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_purchase_orders",
"description": "List purchase orders, optionally filtered to one site and/or to verified status. Use for 'show me unverified POs', 'what POs are on the Hills'. For the contents of a specific PO use get_purchase_order.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"verified": {
"type": "boolean",
"description": "true for verified only, false for unverified only"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_delivery_note",
"description": "Full detail for ONE delivery note including every item received against it, the purchase order it was matched to, who signed for it, and the OCR confidence and processing status. Use for 'what came in on DN X', 'was delivery note X verified'.",
"inputSchema": {
"type": "object",
"properties": {
"deliveryNoteNumber": {
"type": "string",
"description": "Delivery note number"
}
},
"required": [
"deliveryNoteNumber"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_vehicle_fuel_log",
"description": "Fill-up history for ONE vehicle: date, litres, odometer reading, cost, and how each fill compared with the vehicle's configured consumption limit. Use for 'show me the Grader's fuel history', 'why is vehicle X flagged'. Accepts a description, QR code, or id.",
"inputSchema": {
"type": "object",
"properties": {
"vehicle": {
"type": "string",
"description": "Vehicle description, QR code, or record id"
},
"limit": {
"type": "number",
"description": "Most recent N fill-ups, default 20"
}
},
"required": [
"vehicle"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "top_workers",
"description": "Workers ranked by hours worked, with their labour cost. Optionally limited to one site. Use for 'who worked the most hours', 'top 10 workers', 'labour cost by person'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"limit": {
"type": "number",
"description": "How many to return, default 10"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_delivery_notes",
"description": "List delivery notes with their value, matched purchase order, date received and verification status. Sort by value to find the highest or lowest cost note. A note's value is the quantity received times the matched PO item rate; lines never matched to a PO count as zero. Use for 'list delivery notes', 'highest cost delivery note', 'what came in last month'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"verified": {
"type": "boolean",
"description": "true = verified only, false = unverified only"
},
"sortBy": {
"type": "string",
"enum": [
"value",
"dateReceived"
],
"description": "Defaults to dateReceived"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to desc"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_plant_hours",
"description": "Plant and equipment hour-meter entries: machine, dates, hours run, budgeted hours, cost per hour, value, and whether the entry went over budget. Use for 'which plant went over budget', 'how many hours did the excavator run', 'most expensive plant this month'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"overBudget": {
"type": "boolean",
"description": "true = over-budget entries only"
},
"sortBy": {
"type": "string",
"enum": [
"value",
"hours",
"startDate"
],
"description": "Defaults to startDate"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to desc"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_fixed_costs",
"description": "Individual preliminary-and-general (P&G) overhead entries for a site: description, cost, date and whether it recurs monthly or is a one-off. Use for 'what are my overheads on X', 'biggest fixed cost', 'what one-off costs did we have'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"recurring": {
"type": "boolean",
"description": "true = monthly recurring only, false = one-off only"
},
"sortBy": {
"type": "string",
"enum": [
"cost",
"date"
],
"description": "Defaults to date"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to desc"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_income_claims",
"description": "Payment claims raised against a contract: claim number, period, gross amount, who certified it, when it was paid, and whether it is draft, certified or paid. Use for 'what have we claimed', 'which claims are unpaid', 'biggest claim'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"status": {
"type": "string",
"enum": [
"draft",
"certified",
"paid"
],
"description": "Filter to one status"
},
"sortBy": {
"type": "string",
"enum": [
"amount",
"periodEnd"
],
"description": "Defaults to periodEnd"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to desc"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_employees",
"description": "The workforce roster: name, employee number, position, hourly rate, standard hours per day, and whether permanent or casual. Includes people with no hours logged, which top_workers does not. Use for 'who works on site X', 'what is someone's rate', 'how many casuals do we have'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"workerType": {
"type": "string",
"enum": [
"permanent",
"casual"
]
},
"sortBy": {
"type": "string",
"enum": [
"name",
"rate"
],
"description": "Defaults to name"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to asc for name, desc for rate"
},
"limit": {
"type": "number",
"description": "Defaults to 50"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_imports",
"description": "Document import batches — timesheets, fuel logs, purchase orders, delivery notes and plant hours — with their processing status, attempt count and any error. Use for 'what uploads failed', 'did my timesheet import work', 'what is stuck'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"status": {
"type": "string",
"enum": [
"pending",
"processing",
"processed",
"failed",
"dead"
],
"description": "Filter to one status"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "attendance_today",
"description": "Today's attendance from MEGA Clock scans, exactly as the On-site page shows it: who is on site now (and since when), who has clocked out, regulars from the last 7 days who have not arrived, and earlier-day clock-ins with no scan-out (needs attention). Counts plus names. Use for 'who is on site', 'attendance today', 'who hasn't arrived', 'missed scan-outs'. Optionally one site.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id; omit for all sites"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "supplier_balances",
"description": "What is owed to whom: per supplier, value ordered, value delivered and value still outstanding (computed live from PO lines and delivery notes, as on the Suppliers page), plus delivery notes still unresolved against them. Sorted by outstanding, highest first, with totals. Use for 'what do we owe', 'supplier balances', 'how much is outstanding with Afrisam'. Optionally filter by supplier name.",
"inputSchema": {
"type": "object",
"properties": {
"supplier": {
"type": "string",
"description": "Part of a supplier name; omit for all suppliers"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
},
"period": {
"type": "string",
"enum": [
"thisMonth",
"thisWeek",
"month1",
"month2",
"month3"
],
"description": "Only suppliers with goods RECEIVED in this period (by the delivery note's date received), with receivedInPeriod per supplier and in total — what is payable for that period, as on the Suppliers page. thisMonth = the current calendar month; month1/2/3 = one/two/three months back. Use for 'who do I need to pay this month'."
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "delivery_note_queue",
"description": "Where delivery-note paperwork stands: the inbox board's counts per column (reading photos, check grouping, identify document and PO, match delivery note to PO, ready to post, failed; completed documents have a separate page) and the work queue of documents that cannot be posted yet — how many lack a supplier, a purchase order or matched lines, grouped by reason, with examples. Use for 'what's in the DN inbox', 'what delivery notes are stuck', 'what needs matching'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "plant_equipment_summary",
"description": "Plant and equipment (hour-meter machines) from fuel logs: per item hours worked (guarded meter diffs — a reset or impossible reading counts as 0 h and is reported as rejected), value at its cost per hour, litres and L/h, with totals and charts. Use for 'how many hours did the plant run', 'what is my plant worth this contract', 'which machine uses the most fuel per hour', 'rejected meter readings', '/plant'. Optionally one site.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id; omit for all sites"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "rain_outlook",
"description": "7-day rain forecast per site (Open-Meteo, as on the Overview rain card): rain and chance per day, each day marked dry / watch / likely stop for earthworks, with the count of likely-stop days. Also names sites with no map location. Use for 'will it rain', 'weather this week', 'rain stops', '/today'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_change_requests",
"description": "Lists app change requests Ask has filed for the developer (new pages, columns, buttons or report layouts users asked for), optionally filtered by status. Read-only — status is changed from the Change Requests page, not here.",
"inputSchema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"open",
"planned",
"done",
"declined"
]
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
]
},
{
"check": "MCP tools/call list_sites",
"passed": true,
"shape": "array",
"rowCount": 3
},
{
"check": "MCP tools/call list_employees",
"passed": true,
"shape": "object",
"keys": [
"scope",
"total",
"showing",
"permanent",
"casual",
"employees"
]
},
{
"check": "MCP tools/call list_purchase_orders",
"passed": true,
"shape": "object",
"keys": [
"scope",
"total",
"showing",
"purchaseOrders"
]
},
{
"check": "MCP tools/call get_pending_verification",
"passed": true,
"shape": "object",
"keys": [
"purchaseOrdersPending",
"deliveryNotesPending",
"purchaseOrders",
"deliveryNotes"
]
},
{
"check": "PocketBase health",
"passed": true,
"status": 200
},
{
"check": "PocketBase authenticated records GET",
"passed": true,
"status": 200
},
{
"check": "http://127.0.0.1:6004: Public app health",
"passed": true,
"route": "/api/health",
"status": 200,
"contentType": "application/json",
"keys": [
"status",
"uptime",
"timestamp"
]
},
{
"check": "http://127.0.0.1:6004: 200 means no login was required; 401 means login is required",
"passed": true,
"route": "/api/dashboard-attention",
"status": 200,
"contentType": "application/json",
"keys": [
"plant",
"financial",
"onSite"
]
},
{
"check": "http://127.0.0.1:6004: Missing ingest secret must be rejected",
"passed": true,
"route": "/api/purchase-orders/by-number/[poNumber]/items",
"status": 401,
"contentType": "application/json",
"keys": [
"error"
],
"error": "unauthorized"
},
{
"check": "http://127.0.0.1:6004: Authenticated read-only PO lookup",
"passed": true,
"route": "/api/purchase-orders/by-number/[poNumber]/items",
"status": 200,
"contentType": "application/json",
"keys": [
"po",
"items"
]
},
{
"check": "http://127.0.0.1:6004: 401/404 is not an MCP handshake; no HTTP MCP route exists in this checkout",
"passed": true,
"route": "/api/mcp",
"status": 404,
"contentType": "text/html; charset=utf-8",
"keys": []
}
],
"summary": {
"passed": 13,
"failed": 0
}
}

View file

@ -0,0 +1,6 @@
[mcp_servers.mega-infrastructure]
command = "/Volumes/KIOXIA/.hermes/node/bin/node"
args = ["--import","tsx","--env-file=/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/.env.local","/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/src/mcp/server.ts"]
cwd = "/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE"
startup_timeout_sec = 20
tool_timeout_sec = 60

View file

@ -0,0 +1,11 @@
MCP_PUBLIC_URL=https://mega.dash.apphub.co.za/api/mcp
MCP_ALLOWED_ORIGINS=https://mega.dash.apphub.co.za
# Generate real credentials using scripts/generate-mcp-key.mjs; this hash is a placeholder.
MCP_CLIENTS_JSON=[{"id":"codex","tokenHash":"REPLACE_WITH_64_CHARACTER_SHA256_HASH","permissions":["read","write"]}]
MCP_JOB_SCHEDULE_CONFIGURED=false
# Existing app server credentials; never supply these to MCP clients.
NEXT_PUBLIC_POCKETBASE_URL=https://pb.mega.infra.apphub.co.za
POCKETBASE_ADMIN_EMAIL=YOUR_SERVER_ADMIN_EMAIL
POCKETBASE_ADMIN_PASSWORD=YOUR_SERVER_ADMIN_PASSWORD
# Optional existing provider settings are required only for AI processing.
MINIMAX_API_KEY=YOUR_PROVIDER_KEY

View file

@ -0,0 +1,7 @@
mcp_servers:
mega:
url: https://mega.dash.apphub.co.za/api/mcp
headers:
Authorization: "Bearer ${MEGA_MCP_TOKEN}"
timeout: 300
connect_timeout: 30

15
docs/setup/mega-mcp.json Normal file
View file

@ -0,0 +1,15 @@
{
"mcpServers": {
"mega-infrastructure": {
"type": "stdio",
"command": "/Volumes/KIOXIA/.hermes/node/bin/node",
"args": [
"--import",
"tsx",
"--env-file=/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/.env.local",
"/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/src/mcp/server.ts"
],
"cwd": "/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE"
}
}
}

View file

@ -0,0 +1,5 @@
[mcp_servers.mega]
url = "https://mega.dash.apphub.co.za/api/mcp"
bearer_token_env_var = "MEGA_MCP_TOKEN"
startup_timeout_sec = 30
tool_timeout_sec = 300

View file

@ -0,0 +1,9 @@
{
"mcpServers": {
"mega": {
"type": "http",
"url": "https://mega.dash.apphub.co.za/api/mcp",
"headers": { "Authorization": "Bearer YOUR_MEGA_MCP_KEY" }
}
}
}

View file

@ -0,0 +1,704 @@
{
"checkedAt": "2026-10-04T22:26:56.127Z",
"timezone": "Africa/Johannesburg",
"envFile": "cooler.env",
"localAuthMode": "pin",
"checks": [
{
"check": "MCP initialize",
"passed": true,
"server": {
"name": "mega-infrastructure",
"version": "1.0.0"
}
},
{
"check": "MCP tools/list",
"passed": true,
"toolCount": 27,
"tools": [
{
"name": "list_sites",
"description": "List every construction site with its contract value, total spent, percent burnt, projected final cost and projected profit. Use this for portfolio-wide questions like 'how are my sites doing' or 'which site is losing money'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "site_report",
"description": "Visual report for ONE site: KPI tiles, burn by category, weekly income vs expenses, daily fuel, top payroll, open POs and fuel attention, with charts. Use for '/site', 'report on X', 'show me X'. Accepts a site name, project code or id.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
}
},
"required": [
"site"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "portfolio_report",
"description": "Visual report across ALL sites: contract vs spent vs projected, burn vs time elapsed, and which sites are at risk. Use for '/portfolio' or 'how are all my sites doing'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "problem_report",
"description": "Problem report: everything wrong right now, ranked high/medium/low — sites heading for a loss or over plan, fuel alerts, fuel limits in the wrong unit, rejected hour-meter readings, failed imports, delivery notes stuck in the work queue, unverified and overdue purchase orders, missed scan-outs, sites with no income recorded. Each finding has a display-ready title, detail and dashboard link. Use for '/problems', 'what's wrong', 'what needs my attention'. Optionally one site.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id; omit for all sites"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "improvement_report",
"description": "Room-for-improvement report: setup and data-quality gaps that make the dashboard's figures less trustworthy — sites missing hours/week, start date, duration or map location; vehicles with no unit type or fuel limit; plant with no cost per hour; possible duplicate vehicles; workers with no rate or no site; test-looking entries; monthly overheads not yet logged this month. Each finding has a display-ready title, detail and link. Use for '/improvements', 'what should we clean up', 'how can the numbers be more accurate'. Optionally one site.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id; omit for all sites"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_site_financials",
"description": "Full financial breakdown for ONE site: contract value, spend split by overheads/payroll/materials/fuel, remaining budget, and the projected final cost and profit at the current run rate. Use for 'what is my projected profit on X' or 'where is the money going on X'. Accepts a site name, project code, or id.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code (e.g. C26/09), or record id"
}
},
"required": [
"site"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_site_trend",
"description": "Income versus expenses over time for one site, bucketed by month (default, last 6) or week (last 8). Use for 'how did spending trend on X' or 'was X profitable last month'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or record id"
},
"granularity": {
"type": "string",
"enum": [
"month",
"week"
],
"description": "Defaults to month"
}
},
"required": [
"site"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "vehicles_needing_attention",
"description": "Vehicles whose fuel consumption breaches their configured limit, with alert and warning counts. Alerts are more than 20% worse than the limit; warnings are any shortfall. Dismissed vehicles are excluded unless includeDismissed is true. Use for 'which vehicles need attention', 'which trucks are burning too much fuel'.",
"inputSchema": {
"type": "object",
"properties": {
"includeDismissed": {
"type": "boolean",
"description": "Include vehicles whose alert the user already acknowledged"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_fuel_summary",
"description": "Fleet-wide fuel totals: litres, spend at the current pump rate, number of vehicles, and how many fill-ups are flagged. Use for 'how much am I spending on fuel' or 'what is my total fuel usage'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_pending_verification",
"description": "Purchase orders and delivery notes still awaiting human verification. Use for 'what needs my approval', 'what is outstanding', 'what should I look at today'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_purchase_order",
"description": "Full detail for ONE purchase order including every line item: material code, description, quantity ordered, quantity still outstanding, unit, rate and amount. Use for 'what's on PO X', 'what did we order from supplier Y on that PO', 'what's still outstanding on PO X'.",
"inputSchema": {
"type": "object",
"properties": {
"poNumber": {
"type": "string",
"description": "Purchase order number, e.g. MEGCPO9199"
}
},
"required": [
"poNumber"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_purchase_orders",
"description": "List purchase orders, optionally filtered to one site and/or to verified status. Use for 'show me unverified POs', 'what POs are on the Hills'. For the contents of a specific PO use get_purchase_order.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"verified": {
"type": "boolean",
"description": "true for verified only, false for unverified only"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_delivery_note",
"description": "Full detail for ONE delivery note including every item received against it, the purchase order it was matched to, who signed for it, and the OCR confidence and processing status. Use for 'what came in on DN X', 'was delivery note X verified'.",
"inputSchema": {
"type": "object",
"properties": {
"deliveryNoteNumber": {
"type": "string",
"description": "Delivery note number"
}
},
"required": [
"deliveryNoteNumber"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get_vehicle_fuel_log",
"description": "Fill-up history for ONE vehicle: date, litres, odometer reading, cost, and how each fill compared with the vehicle's configured consumption limit. Use for 'show me the Grader's fuel history', 'why is vehicle X flagged'. Accepts a description, QR code, or id.",
"inputSchema": {
"type": "object",
"properties": {
"vehicle": {
"type": "string",
"description": "Vehicle description, QR code, or record id"
},
"limit": {
"type": "number",
"description": "Most recent N fill-ups, default 20"
}
},
"required": [
"vehicle"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "top_workers",
"description": "Workers ranked by hours worked, with their labour cost. Optionally limited to one site. Use for 'who worked the most hours', 'top 10 workers', 'labour cost by person'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"limit": {
"type": "number",
"description": "How many to return, default 10"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_delivery_notes",
"description": "List delivery notes with their value, matched purchase order, date received and verification status. Sort by value to find the highest or lowest cost note. A note's value is the quantity received times the matched PO item rate; lines never matched to a PO count as zero. Use for 'list delivery notes', 'highest cost delivery note', 'what came in last month'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"verified": {
"type": "boolean",
"description": "true = verified only, false = unverified only"
},
"sortBy": {
"type": "string",
"enum": [
"value",
"dateReceived"
],
"description": "Defaults to dateReceived"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to desc"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_plant_hours",
"description": "Plant and equipment hour-meter entries: machine, dates, hours run, budgeted hours, cost per hour, value, and whether the entry went over budget. Use for 'which plant went over budget', 'how many hours did the excavator run', 'most expensive plant this month'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"overBudget": {
"type": "boolean",
"description": "true = over-budget entries only"
},
"sortBy": {
"type": "string",
"enum": [
"value",
"hours",
"startDate"
],
"description": "Defaults to startDate"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to desc"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_fixed_costs",
"description": "Individual preliminary-and-general (P&G) overhead entries for a site: description, cost, date and whether it recurs monthly or is a one-off. Use for 'what are my overheads on X', 'biggest fixed cost', 'what one-off costs did we have'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"recurring": {
"type": "boolean",
"description": "true = monthly recurring only, false = one-off only"
},
"sortBy": {
"type": "string",
"enum": [
"cost",
"date"
],
"description": "Defaults to date"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to desc"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_income_claims",
"description": "Payment claims raised against a contract: claim number, period, gross amount, who certified it, when it was paid, and whether it is draft, certified or paid. Use for 'what have we claimed', 'which claims are unpaid', 'biggest claim'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"status": {
"type": "string",
"enum": [
"draft",
"certified",
"paid"
],
"description": "Filter to one status"
},
"sortBy": {
"type": "string",
"enum": [
"amount",
"periodEnd"
],
"description": "Defaults to periodEnd"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to desc"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_employees",
"description": "The workforce roster: name, employee number, position, hourly rate, standard hours per day, and whether permanent or casual. Includes people with no hours logged, which top_workers does not. Use for 'who works on site X', 'what is someone's rate', 'how many casuals do we have'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"workerType": {
"type": "string",
"enum": [
"permanent",
"casual"
]
},
"sortBy": {
"type": "string",
"enum": [
"name",
"rate"
],
"description": "Defaults to name"
},
"order": {
"type": "string",
"enum": [
"asc",
"desc"
],
"description": "Defaults to asc for name, desc for rate"
},
"limit": {
"type": "number",
"description": "Defaults to 50"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_imports",
"description": "Document import batches — timesheets, fuel logs, purchase orders, delivery notes and plant hours — with their processing status, attempt count and any error. Use for 'what uploads failed', 'did my timesheet import work', 'what is stuck'.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id"
},
"status": {
"type": "string",
"enum": [
"pending",
"processing",
"processed",
"failed",
"dead"
],
"description": "Filter to one status"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "attendance_today",
"description": "Today's attendance from MEGA Clock scans, exactly as the On-site page shows it: who is on site now (and since when), who has clocked out, regulars from the last 7 days who have not arrived, and earlier-day clock-ins with no scan-out (needs attention). Counts plus names. Use for 'who is on site', 'attendance today', 'who hasn't arrived', 'missed scan-outs'. Optionally one site.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id; omit for all sites"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "supplier_balances",
"description": "What is owed to whom: per supplier, value ordered, value delivered and value still outstanding (computed live from PO lines and delivery notes, as on the Suppliers page), plus delivery notes still unresolved against them. Sorted by outstanding, highest first, with totals. Use for 'what do we owe', 'supplier balances', 'how much is outstanding with Afrisam'. Optionally filter by supplier name.",
"inputSchema": {
"type": "object",
"properties": {
"supplier": {
"type": "string",
"description": "Part of a supplier name; omit for all suppliers"
},
"limit": {
"type": "number",
"description": "Defaults to 25"
},
"period": {
"type": "string",
"enum": [
"thisMonth",
"thisWeek",
"month1",
"month2",
"month3"
],
"description": "Only suppliers with goods RECEIVED in this period (by the delivery note's date received), with receivedInPeriod per supplier and in total — what is payable for that period, as on the Suppliers page. thisMonth = the current calendar month; month1/2/3 = one/two/three months back. Use for 'who do I need to pay this month'."
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "delivery_note_queue",
"description": "Where delivery-note paperwork stands: the inbox board's counts per column (reading photos, check grouping, identify document and PO, match delivery note to PO, ready to post, failed; completed documents have a separate page) and the work queue of documents that cannot be posted yet — how many lack a supplier, a purchase order or matched lines, grouped by reason, with examples. Use for 'what's in the DN inbox', 'what delivery notes are stuck', 'what needs matching'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "plant_equipment_summary",
"description": "Plant and equipment (hour-meter machines) from fuel logs: per item hours worked (guarded meter diffs — a reset or impossible reading counts as 0 h and is reported as rejected), value at its cost per hour, litres and L/h, with totals and charts. Use for 'how many hours did the plant run', 'what is my plant worth this contract', 'which machine uses the most fuel per hour', 'rejected meter readings', '/plant'. Optionally one site.",
"inputSchema": {
"type": "object",
"properties": {
"site": {
"type": "string",
"description": "Site name, project code, or id; omit for all sites"
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "rain_outlook",
"description": "7-day rain forecast per site (Open-Meteo, as on the Overview rain card): rain and chance per day, each day marked dry / watch / likely stop for earthworks, with the count of likely-stop days. Also names sites with no map location. Use for 'will it rain', 'weather this week', 'rain stops', '/today'.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "list_change_requests",
"description": "Lists app change requests Ask has filed for the developer (new pages, columns, buttons or report layouts users asked for), optionally filtered by status. Read-only — status is changed from the Change Requests page, not here.",
"inputSchema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"open",
"planned",
"done",
"declined"
]
}
},
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
]
},
{
"check": "MCP tools/call list_sites",
"passed": true,
"shape": "array",
"rowCount": 3
},
{
"check": "MCP tools/call list_employees",
"passed": true,
"shape": "object",
"keys": [
"scope",
"total",
"showing",
"permanent",
"casual",
"employees"
]
},
{
"check": "MCP tools/call list_purchase_orders",
"passed": true,
"shape": "object",
"keys": [
"scope",
"total",
"showing",
"purchaseOrders"
]
},
{
"check": "MCP tools/call get_pending_verification",
"passed": true,
"shape": "object",
"keys": [
"purchaseOrdersPending",
"deliveryNotesPending",
"purchaseOrders",
"deliveryNotes"
]
},
{
"check": "PocketBase health",
"passed": true,
"status": 200
},
{
"check": "https://mega.dash.apphub.co.za: Public app health",
"passed": true,
"route": "/api/health",
"status": 200,
"contentType": "application/json",
"keys": [
"status",
"uptime",
"timestamp"
]
},
{
"check": "https://mega.dash.apphub.co.za: 200 means no login was required; 401 means login is required",
"passed": true,
"route": "/api/dashboard-attention",
"status": 401,
"contentType": "application/json",
"keys": [
"error"
],
"error": "Sign in required"
},
{
"check": "https://mega.dash.apphub.co.za: Missing ingest secret must be rejected",
"passed": true,
"route": "/api/purchase-orders/by-number/[poNumber]/items",
"status": 401,
"contentType": "application/json",
"keys": [
"error"
],
"error": "unauthorized"
},
{
"check": "https://mega.dash.apphub.co.za: Authenticated read-only PO lookup",
"passed": true,
"route": "/api/purchase-orders/by-number/[poNumber]/items",
"status": 200,
"contentType": "application/json",
"keys": [
"po",
"items"
]
},
{
"check": "https://mega.dash.apphub.co.za: 401/404 is not an MCP handshake; no HTTP MCP route exists in this checkout",
"passed": true,
"route": "/api/mcp",
"status": 401,
"contentType": "application/json",
"keys": [
"error"
],
"error": "Sign in required"
}
],
"summary": {
"passed": 12,
"failed": 0
}
}

View file

@ -0,0 +1,202 @@
{
"http": {
"checkedAt": "2026-10-05T00:15:47.847Z",
"timezone": "Africa/Johannesburg",
"envFile": ".env.local",
"localAuthMode": "pin",
"checks": [
{
"check": "MCP initialize",
"passed": true,
"server": {
"name": "mega-infrastructure",
"version": "1.1.0"
}
},
{
"check": "MCP tools/list",
"passed": true,
"toolCount": 35
},
{
"check": "MCP tools/call list_sites",
"passed": true,
"shape": "array",
"rowCount": 3
},
{
"check": "MCP tools/call list_employees",
"passed": true,
"shape": "object",
"keys": [
"scope",
"total",
"showing",
"permanent",
"casual",
"employees"
]
},
{
"check": "MCP tools/call list_purchase_orders",
"passed": true,
"shape": "object",
"keys": [
"scope",
"total",
"showing",
"purchaseOrders"
]
},
{
"check": "MCP tools/call get_pending_verification",
"passed": true,
"shape": "object",
"keys": [
"purchaseOrdersPending",
"deliveryNotesPending",
"purchaseOrders",
"deliveryNotes"
]
},
{
"check": "PocketBase health",
"passed": true,
"status": 200
},
{
"check": "PocketBase authenticated records GET",
"passed": true,
"status": 200
},
{
"check": "http://127.0.0.1:6017: Public app health",
"passed": true,
"route": "/api/health",
"status": 200,
"contentType": "application/json",
"keys": [
"status",
"uptime",
"timestamp"
]
},
{
"check": "http://127.0.0.1:6017: 200 means no login was required; 401 means login is required",
"passed": true,
"route": "/api/dashboard-attention",
"status": 200,
"contentType": "application/json",
"keys": [
"plant",
"financial",
"onSite"
]
},
{
"check": "http://127.0.0.1:6017: Missing ingest secret must be rejected",
"passed": true,
"route": "/api/purchase-orders/by-number/[poNumber]/items",
"status": 401,
"contentType": "application/json",
"keys": [
"error"
],
"error": "unauthorized"
},
{
"check": "http://127.0.0.1:6017: Authenticated read-only PO lookup",
"passed": true,
"route": "/api/purchase-orders/by-number/[poNumber]/items",
"status": 200,
"contentType": "application/json",
"keys": [
"po",
"items"
]
},
{
"check": "http://127.0.0.1:6017: Unauthenticated MCP probe; initialization is checked separately",
"passed": true,
"route": "/api/mcp",
"status": 401,
"contentType": "application/json",
"keys": [
"error"
],
"error": "unauthorized"
}
],
"summary": {
"passed": 13,
"failed": 0
}
},
"capabilities": {
"checkedAt": "2026-10-05T00:16:52.975Z",
"checks": [
{
"check": "reader discovery",
"passed": true,
"toolCount": 35,
"writeToolCount": 0
},
{
"check": "writer discovery",
"passed": true,
"toolCount": 53,
"writeToolCount": 18
},
{
"check": "invalid bearer",
"passed": true,
"status": 401
},
{
"check": "disallowed origin",
"passed": true,
"status": 403
},
{
"check": "unauthorized document",
"passed": true,
"status": 401
},
{
"check": "reader scheduler",
"passed": true,
"status": 403
}
]
},
"unitTests": {
"files": 248,
"passed": 2256,
"failed": 0
},
"scriptTests": {
"passed": 4,
"failed": 0
},
"typescript": "passed: npx tsc --noEmit --incremental false",
"relevantEslint": "passed: MCP, purchasing services and affected API routes",
"build": "passed: npm run build; Next.js 16 standalone production build",
"standaloneSmoke": {
"checkedAt": "2026-10-05T00:36:09Z",
"origin": "http://127.0.0.1:6005",
"httpChecks": { "passed": 13, "failed": 0 },
"readerCredential": { "tools": 35, "writeToolsDiscovered": 0, "readCallsPassed": 4 },
"writeCredential": { "tools": 53, "writeToolsDiscovered": 18, "mutationsInvoked": 0 },
"invalidBearerStatus": 401,
"untrustedOriginStatus": 403,
"unauthenticatedDocumentStatus": 401
},
"deployment": "not deployed",
"productionSchemaDryRun": {
"checkedOn": "2026-10-05",
"applied": false,
"missingCollections": ["mcp_operations", "mcp_jobs", "mcp_resource_locks"]
},
"stagingDatabase": "not configured; mutation/PDF/posting acceptance pending",
"dockerImage": "not built: Docker daemon unavailable at unix:///Volumes/KIOXIA/.docker/run/docker.sock"
}

View file

@ -0,0 +1,102 @@
# MEGA remote MCP: connection and deployment guide
Prepared 5 October 2026. The endpoint is implemented on branch `codex/remote-purchasing-mcp`; production activation requires deployment of this branch and the additive MCP schema. The production URL is a target until its external handshake passes.
## Connection
| Setting | Value |
| --- | --- |
| Remote URL | `https://mega.dash.apphub.co.za/api/mcp` |
| Transport | MCP Streamable HTTP, stateless JSON responses |
| Authentication | `Authorization: Bearer <your dedicated MCP key>` |
| Hosting | Existing Next.js app, existing Coolify container/domain/HTTPS |
| Server credentials | Existing PocketBase admin credentials, kept server-side |
| Local stdio | Preserved, read-only by default |
| Delete access | No business delete/removal tools; record/batch deletion and source replacement rejected |
| Stock scope | Received and outstanding PO quantities; no physical warehouse/site inventory ledger |
Separate credentials were generated in the ignored `.env.mcp.local` file in the feature worktree. It contains `MEGA_MCP_CODEX_TOKEN`, `MEGA_MCP_HERMES_TOKEN`, `MEGA_MCP_COWORK_TOKEN`, `MEGA_MCP_READER_TOKEN`, `MEGA_MCP_SCHEDULER_TOKEN` and the server's `MCP_CLIENTS_JSON`. File permissions are 0600. Actual values are deliberately kept out of this shareable document and Git. The first three grant all-site read/write owner access; reader grants reads only; scheduler grants job processing only. Each can be revoked by removing its entry from the server JSON and redeploying/reloading the app.
The raw tokens belong in each client's secure configuration. Coolify needs only the credential IDs, permissions and SHA-256 hashes in `MCP_CLIENTS_JSON`, plus the existing server/PocketBase/provider environment. Do not paste PocketBase credentials or the n8n ingest secret into an MCP client. Generate another client credential with `node scripts/generate-mcp-key.mjs`, which prints a fresh token and its matching hash once.
## Clients and cloud access
Any cloud agent that supports Streamable HTTP plus bearer headers can use this URL once deployed and reachable from its runtime. The MCP server runs on Coolify, so its availability is independent of the owner's Mac. The client or scheduled agent still needs its own running execution environment and securely available key.
Codex supports remote URLs and `bearer_token_env_var`. Use [mega-mcp.remote.codex.toml](./mega-mcp.remote.codex.toml), setting `MEGA_MCP_TOKEN` securely in the runtime that makes the connection. A desktop scheduled task depends on its configured host. For Work Cloud or another cloud executor, verify that the selected runtime receives the remote MCP config and bearer secret; local Codex settings alone do not prove that the cloud agent can connect. See [official Codex MCP settings](https://learn.chatgpt.com/docs/extend/mcp?surface=cli).
Hermes supports URL/header configuration and secret environment references. Use [mega-mcp.hermes.yaml](./mega-mcp.hermes.yaml); place the Hermes token in its active secret scope as `MEGA_MCP_TOKEN`. See [official Hermes MCP reference](https://hermes-agent.nousresearch.com/docs/reference/mcp-config-reference).
For Claude Cowork/custom remote connectors, use the same URL and a fixed `Authorization` request header with the Cowork token. Current Claude documentation describes this request-header authentication; remote calls originate from Anthropic infrastructure and need public reachability. The actual user's Cowork connection has not been tested. See [official remote connector setup](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp).
For clients with `mcpServers` JSON configuration, [mega-mcp.remote.json](./mega-mcp.remote.json) supplies the shape. Replace the placeholder in a local secret-managed copy; configuration support varies by client. OAuth is not implemented in this fixed-credential release.
## Tools and workflow
The original 27 read tools remain available. Purchasing tools add bounded record/detail reads, reconciliation, source/proof downloads and operation status. A read key discovers only read tools; a write key also discovers the named purchasing mutations. Tool annotations aid clients, while permission checks and the database boundary enforce access.
Write tools cover manual PO creation/header editing/line editing/verification; PO and DN source uploads; candidate creation/corrections/grouping; supplier guidance; supplier/PO/item resolution; candidate preview processing; durable batch processing; explicit line receipt posting; permitted legacy receipt correction; and delivery verification. There are no generic database, SQL, Ask-action, account-management or arbitrary HTTP tools.
Every write requires a fresh UUID `operationId`. Retry the same operation with the same arguments after a lost connection: the journal returns its saved committed result. Reusing the ID for different arguments fails. An uncertain operation needs reconciliation rather than a blind duplicate write. `get_mcp_operation` reads only the credential's operations. Posted per-line receipts are immutable, as in the app; the legacy correction tool refuses them.
Suggested sequence:
1. Read the PO and its lines; inspect received/outstanding quantities.
2. Upload one source at a time, using stable operation IDs. Delivery uploads may add to an existing batch. PDFs/images are bounded to 20 MiB and PDFs to 100 pages.
3. Queue `process_delivery_batch`; poll `get_mcp_operation`. Grouping/review may still need operator decisions.
4. Correct permitted candidate fields and match supplier, PO and individual lines. Matching alone books nothing.
5. Call `process_delivery_candidate` for the current comparison preview.
6. Call `post_delivery_line` with the preview hash, source-line IDs, allocation index and `confirmation.confirmed=true`. Review changes/excess require the existing confirmation reason and acknowledgment fields.
7. Re-read PO detail/reconciliation to verify received/outstanding quantities and retrieve proof PDFs.
Some broad regroupings exceed the 50-request transactional batch budget. They fail before any business commit instead of silently splitting an operation. Retained source files and superseded candidates are not deleted. Failed or duplicate PO extraction retains MCP-uploaded original documents.
`get_document_download` returns filename, MIME, size and an app URL requiring the same bearer header. A link alone grants no access. `read_document` returns up to 2 MiB per base64 segment with offset/nextOffset for larger documents. Candidate proof PDFs use saved source page order. There are no returned PocketBase admin/file tokens or arbitrary caller-selected file paths.
## Coolify environment and deployment
Use [mega-mcp.env.example](./mega-mcp.env.example) for variable names. Set:
- `MCP_PUBLIC_URL=https://mega.dash.apphub.co.za/api/mcp`.
- `MCP_ALLOWED_ORIGINS=https://mega.dash.apphub.co.za` or a comma-separated list of approved browser origins. Server clients may omit Origin.
- `MCP_CLIENTS_JSON` with real generated hashes and selected permissions. Invalid/missing config returns 503 on MCP routes while dashboard health remains available.
- Existing `NEXT_PUBLIC_POCKETBASE_URL`, server admin email/password, optional preset admin token and processing provider keys. The dedicated purchasing client refreshes an expired preset token at initialization.
- `MCP_JOB_SCHEDULE_CONFIGURED=true` only after the processing schedule actually exists.
Before deployment, run the additive schema migration against an isolated/staging PocketBase database:
```sh
node --env-file=.env.staging.local scripts/migrations/2026-10-05-add-mcp-operations.mjs
node --env-file=.env.staging.local scripts/migrations/2026-10-05-add-mcp-operations.mjs --apply
```
The first command is a read-only dry run. The second creates/extends private operations, jobs and resource lock collections, backs up existing schema before modification, and verifies fields/indexes. It preserves existing business data and rules. Existing DN staged-ingestion/line-posting schemas and PocketBase transactional batch support are also required. Writes fail closed when required schema is absent. Review the schema result before applying to production.
The 5 October read-only dry run against the currently configured PocketBase found all three MCP collections absent. No schema changes were applied. Apply the additive migration to the database used by the updated deployment before enabling write credentials.
Deploy the existing app image; do not add a second container, DNS entry or public port. Existing `/api/health` remains the liveness endpoint. The MCP POST handshake is its separate readiness check. The updated Docker exclusions cover local credentials/agent state. The Docker image itself was not built because the Docker daemon was unavailable at `unix:///Volumes/KIOXIA/.docker/run/docker.sock`; the standalone Next.js build succeeded.
Configure an external scheduler, such as the existing n8n instance, to call:
```text
POST https://mega.dash.apphub.co.za/api/mcp/jobs/tick
Authorization: Bearer <scheduler credential>
```
No body is needed. Run once per minute. One request claims one job; PocketBase persists status, retries and leases. Reads and browser write credentials cannot tick jobs. Processing retries at most three times with increasing delays. Deadlines are checked between source/page/group stages; an in-flight provider request may finish after the cooperative deadline, so configure the Coolify proxy timeout to 600 seconds. Cached reads allow later attempts to continue. Do not invoke the old global DN tick with an MCP key or reuse the ingest secret. No background promise continues invisibly after a response.
## Verification and current limits
Final local validation on 5 October 2026: **2,256 tests passed across 248 files**, 4 migration/key script tests passed, TypeScript and relevant ESLint passed, and `npm run build` produced the Next.js standalone production build. Its HTTP MCP endpoint then passed **13/13** local checks. A read credential discovered 35 read tools and completed four live reads against the configured PocketBase; a write credential discovered 53 total tools, including 18 writes, without invoking a mutation. Invalid bearer, untrusted Origin and unauthenticated document requests returned 401, 403 and 401. Details are in [remote-mcp-check-results.json](./remote-mcp-check-results.json).
Read-only HTTP checks can run against the local app or deployed endpoint:
```sh
node scripts/check-connections.mjs --mcp-url http://127.0.0.1:6005/api/mcp http://127.0.0.1:6005
```
The checker uses `MEGA_MCP_TOKEN` or the generated reader credential and prints metadata, never business records or keys. A production HTTP smoke requires the production deployment and a configured reader key.
`scripts/mcp-staging-smoke.mjs` creates/edits/verifies a fixture PO and refuses the known production origin. It requires `MEGA_MCP_STAGING_URL`, `MEGA_MCP_TOKEN`, `MEGA_MCP_STAGING_SITE` and `MEGA_MCP_STAGING_SUPPLIER`. Full source-download/matching/posting exercise additionally requires prepared staged evidence/model configuration. Database fixture tests do not establish that a real deployment's PocketBase schema or client account works.
Production deployment, external MCP initialization, a real staging mutation/PDF/posting exercise and personal Codex/Hermes/Cowork connections remain to be verified. No production business writes or schema changes were performed during implementation. Physical stock-on-hand is outside this release.

View file

@ -0,0 +1,137 @@
/** Read-only live API/MCP smoke check. Prints metadata, never credentials or records. */
import { loadEnvFile } from "node:process";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const root = fileURLToPath(new URL("../", import.meta.url));
const envFile = path.resolve(root, process.env.MEGA_CONNECTION_ENV_FILE || ".env.local");
loadEnvFile(envFile);
const argumentsList=process.argv.slice(2);
const remoteIndex=argumentsList.indexOf("--mcp-url");
const remoteUrl=remoteIndex>=0?argumentsList.splice(remoteIndex,2)[1]:undefined;
if(remoteIndex>=0&&!remoteUrl)throw new Error("--mcp-url requires an endpoint URL");
if(remoteUrl){const url=new URL(remoteUrl);if(!["https:","http:"].includes(url.protocol)||url.username||url.password||url.pathname!=="/api/mcp"||url.search||url.hash)throw new Error("Invalid MCP endpoint URL");}
const origins = argumentsList;
if (!origins.length) origins.push("http://127.0.0.1:6004");
for (const origin of origins) {
const url = new URL(origin);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
throw new Error("Pass app origins only, for example https://mega.dash.apphub.co.za");
}
}
const secrets = Object.entries(process.env)
.filter(([key, value]) => /SECRET|TOKEN|PASSWORD|API_KEY|ADMIN_EMAIL/.test(key) && value?.length >= 6)
.map(([, value]) => value);
const redact = (message) => secrets.reduce((text, value) => text.split(value).join("[redacted]"), String(message));
const report = {
checkedAt: new Date().toISOString(),
timezone: "Africa/Johannesburg",
envFile: path.relative(root, envFile),
localAuthMode: process.env.AUTH_MODE === "users" ? "users" : "pin",
checks: [],
};
function record(check, passed, details = {}) {
report.checks.push({ check, passed, ...details });
}
async function getJson(url, headers) {
const response = await fetch(url, { headers, redirect: "manual", signal: AbortSignal.timeout(30000) });
const text = await response.text();
let body;
try { body = JSON.parse(text); } catch { /* HTML response, e.g. a 404 */ }
return { status: response.status, body, contentType: response.headers.get("content-type") };
}
const transport = remoteUrl ? new StreamableHTTPClientTransport(new URL(remoteUrl),{requestInit:{headers:{Authorization:`Bearer ${process.env.MEGA_MCP_TOKEN||process.env.MEGA_MCP_READER_TOKEN||""}`}}}) : new StdioClientTransport({
command: process.execPath,
args: ["--import", "tsx", `--env-file=${envFile}`, path.join(root, "src/mcp/server.ts")],
cwd: root,
stderr: "pipe",
});
// Drain diagnostics; retain neither business records nor potentially sensitive SDK logs.
if(transport instanceof StdioClientTransport)transport.stderr?.on("data", () => {});
const client = new Client({ name: "mega-connection-check", version: "1.0.0" });
let poNumber;
try {
await client.connect(transport, { timeout: 15000 });
record("MCP initialize", client.getServerVersion()?.name === "mega-infrastructure", { server: client.getServerVersion() });
const { tools } = await client.listTools({}, { timeout: 15000 });
record("MCP tools/list", tools.length > 0, {
toolCount: tools.length,
tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
});
for (const [name, args] of [
["list_sites", {}],
["list_employees", { limit: 1 }],
["list_purchase_orders", { limit: 1 }],
["get_pending_verification", {}],
]) {
try {
const result = await client.callTool({ name, arguments: args }, undefined, { timeout: 45000 });
const raw = result.content?.find((item) => item.type === "text")?.text;
let data;
try { data = JSON.parse(raw); } catch { /* Report invalid JSON as a failure. */ }
const passed = result.isError !== true && data != null && !data.error;
record(`MCP tools/call ${name}`, passed, {
shape: Array.isArray(data) ? "array" : typeof data,
...(Array.isArray(data) ? { rowCount: data.length } : { keys: data ? Object.keys(data) : [] }),
...(!passed ? { error: redact(raw || "No JSON response") } : {}),
});
if (name === "list_purchase_orders" && passed) poNumber = data.purchaseOrders?.[0]?.poNumber;
} catch (error) {
record(`MCP tools/call ${name}`, false, { error: redact(error.message) });
}
}
} catch (error) {
record("MCP connection", false, { error: redact(error.message) });
} finally {
await client.close();
}
const pbBase = process.env.NEXT_PUBLIC_POCKETBASE_URL?.replace(/\/$/, "");
if (pbBase) {
try {
const result = await getJson(`${pbBase}/api/health`);
record("PocketBase health", result.status === 200, { status: result.status });
if (process.env.POCKETBASE_ADMIN_TOKEN) {
const read = await getJson(`${pbBase}/api/collections/sites/records?perPage=1&fields=id`, { Authorization: process.env.POCKETBASE_ADMIN_TOKEN });
record("PocketBase authenticated records GET", read.status === 200 && Array.isArray(read.body?.items), { status: read.status });
}
} catch (error) {
record("PocketBase connection", false, { error: redact(error.message) });
}
}
for (const origin of origins) {
const base = new URL(origin).origin;
const lookupPath = `/api/purchase-orders/by-number/${encodeURIComponent(poNumber || "CONNECTION-CHECK")}/items`;
for (const [route, headers, accept, note] of [
["/api/health", undefined, (r) => r.status === 200 && r.body?.status === "ok", "Public app health"],
["/api/dashboard-attention", undefined, (r) => [200, 401].includes(r.status), "200 means no login was required; 401 means login is required"],
[lookupPath, undefined, (r) => r.status === 401, "Missing ingest secret must be rejected"],
...(process.env.N8N_INGEST_SECRET ? [[lookupPath, { "x-ingest-secret": process.env.N8N_INGEST_SECRET }, (r) => r.status === 200 && Array.isArray(r.body?.items), "Authenticated read-only PO lookup"]] : []),
["/api/mcp", undefined, (r) => [401, 404, 503].includes(r.status), "Unauthenticated MCP probe; initialization is checked separately"],
]) {
try {
const result = await getJson(`${base}${route}`, headers);
record(`${base}: ${note}`, accept(result), {
// A real PO number may be used for the request, but is never persisted.
route: route === lookupPath ? "/api/purchase-orders/by-number/[poNumber]/items" : route,
status: result.status,
contentType: result.contentType,
keys: result.body ? Object.keys(result.body) : [],
...(result.body?.error ? { error: redact(result.body.error) } : {}),
});
} catch (error) {
record(`${base}: ${note}`, false, { error: redact(error.message) });
}
}
}
report.summary = { passed: report.checks.filter((check) => check.passed).length, failed: report.checks.filter((check) => !check.passed).length };
console.log(JSON.stringify(report, null, 2));
if (report.summary.failed) process.exitCode = 1;

View file

@ -0,0 +1,3 @@
import {randomBytes,hash} from 'node:crypto';
const token=randomBytes(32).toString('base64url');
console.log(JSON.stringify({token,tokenHash:hash('sha256',token)},null,2));

View file

@ -0,0 +1,35 @@
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import {randomUUID} from 'node:crypto';
import {fileURLToPath} from 'node:url';
import {resolve} from 'node:path';
export function assertStagingTarget(value){
const url=new URL(value);
if(!['http:','https:'].includes(url.protocol)||url.username||url.password||url.pathname!=='/api/mcp'||url.search||url.hash||url.hostname==='mega.dash.apphub.co.za')throw Error('A separate staging MCP URL is required for mutation checks');
return url;
}
async function main(){
const url=assertStagingTarget(process.env.MEGA_MCP_STAGING_URL||'');
if(!process.env.MEGA_MCP_TOKEN||!process.env.MEGA_MCP_STAGING_SITE||!process.env.MEGA_MCP_STAGING_SUPPLIER)throw Error('Set staging token, fixture site and supplier');
const client=new Client({name:'mega-staging-smoke',version:'1'});
await client.connect(new StreamableHTTPClientTransport(url,{requestInit:{headers:{Authorization:`Bearer ${process.env.MEGA_MCP_TOKEN}`}}}));
const checks=[];
const call=async(name,args)=>{
const result=await client.callTool({name,arguments:args},undefined,{timeout:240000});
if(result.isError)throw Error(`Staging tool ${name} failed`);
const content=result.content.find(c=>c.type==='text');return JSON.parse(content.text);
};
try{
const names=(await client.listTools()).tools.map(t=>t.name);
if(names.some(n=>/delete|remove|clear_all/.test(n)))throw Error('Destructive tool discovered');checks.push('no destructive tools');
const input={operationId:randomUUID(),site:process.env.MEGA_MCP_STAGING_SITE,supplierId:process.env.MEGA_MCP_STAGING_SUPPLIER,number:`MCP-STAGING-${Date.now()}`,items:[{description:'MCP staging fixture',qty:2,rate:10,vat_percent:15,unit:'each'}]};
const created=await call('create_purchase_order',input);const repeat=await call('create_purchase_order',input);
if(created.result.id!==repeat.result.id)throw Error('Operation retry duplicated the order');checks.push('PO create and retry');
const detail=await call('get_purchase_order_detail',{id:created.result.id});
if(detail.po.total_value!==23||detail.items.length!==1)throw Error('PO totals or line count mismatch');checks.push('PO totals');
await call('update_purchase_order_item',{operationId:randomUUID(),id:created.result.id,itemId:created.result.itemIds[0],item:{description:'MCP staging fixture',qty:3,rate:10,vat_percent:15,unit:'each'}});checks.push('PO edit');
await call('set_purchase_order_verification',{operationId:randomUUID(),id:created.result.id,verified:true});checks.push('PO verification');
console.log(JSON.stringify({checkedAt:new Date().toISOString(),origin:url.origin,checks,status:'passed',scope:'PO create/edit/verification; document/matching/posting require staged source fixtures'},null,2));
}finally{await client.close();}
}
if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url))main().catch(()=>{console.error('Staging smoke failed; check isolated fixtures and MCP setup');process.exitCode=1;});

View file

@ -0,0 +1,14 @@
import {test} from 'node:test';
import {strict as assert} from 'node:assert';
import {execFileSync} from 'node:child_process';
import {hash} from 'node:crypto';
import {assertStagingTarget} from './mcp-staging-smoke.mjs';
test('write smoke refuses production and embedded credentials',()=>{
assert.throws(()=>assertStagingTarget('https://mega.dash.apphub.co.za/api/mcp'));
assert.throws(()=>assertStagingTarget('https://key:password@staging.test/api/mcp'));
assert.equal(assertStagingTarget('http://127.0.0.1:6005/api/mcp').hostname,'127.0.0.1');
});
test('generated token has 256 bits and a matching hash',()=>{
const output=JSON.parse(execFileSync(process.execPath,['scripts/generate-mcp-key.mjs'],{encoding:'utf8'}));
assert.equal(Buffer.from(output.token,'base64url').length,32);assert.equal(output.tokenHash,hash('sha256',output.token));
});

View file

@ -1,17 +1,33 @@
import {beforeEach,it,expect,vi} from 'vitest';
const m=vi.hoisted(()=>({admin:vi.fn(),user:vi.fn(),site:vi.fn(),lease:vi.fn(),post:vi.fn()}));
const m=vi.hoisted(()=>({admin:vi.fn(),user:vi.fn(),site:vi.fn(),lease:vi.fn(),poLease:vi.fn(),post:vi.fn(),committed:[] as unknown[][]}));
vi.mock('@/lib/pb',()=>({pbAdmin:m.admin}));
vi.mock('@/lib/auth/guard',()=>({requireApiUser:m.user,requireSiteAccess:m.site}));
vi.mock('@/lib/purchasing/resource-lease',()=>({withResourceLease:m.poLease}));
vi.mock('@/lib/dn-pipeline/lock',()=>({withBatchLease:m.lease}));
vi.mock('@/lib/dn-pipeline/post-line',async()=>({...await vi.importActual('@/lib/dn-pipeline/post-line'),postLineWithinLease:m.post}));
import {POST} from './route';
const batch='batchabcdefghij',note='noteabcdefghijk',po='poabcdefghijklm';
const params={params:Promise.resolve({id:batch,noteId:note})};
const req=(body:unknown={index:0,sourceLineIds:['pipe'],snapshotHash:'hash',confirmation:{confirmed:true}})=>new Request('http://localhost',{method:'POST',body:JSON.stringify(body)});
beforeEach(()=>{vi.resetAllMocks();m.user.mockResolvedValue({id:'user'});m.site.mockReturnValue(null);m.admin.mockResolvedValue({collection:(name:string)=>({getOne:async()=>name==='dn_batches'?{site:'site'}:{batch,purchase_order:po,supplier:'supplierabcdefg'}})});m.lease.mockImplementation(async(_p,_b,_w,fn)=>({acquired:true,value:await fn({batch:vi.fn()})}));m.post.mockResolvedValue({alreadyPosted:false,complete:false});});
beforeEach(()=>{vi.resetAllMocks();m.committed.length=0;m.poLease.mockImplementation(async(_p,_key,_actor,fn)=>({acquired:true,value:await fn({fence:()=>({collection:'mcp_resource_locks',method:'update',id:'lock',data:{}})})}));m.user.mockResolvedValue({id:'user'});m.site.mockReturnValue(null);m.admin.mockResolvedValue({collection:(name:string)=>({getOne:async()=>name==='dn_batches'?{site:'site'}:{batch,purchase_order:po,supplier:'supplierabcdefg'}})});m.lease.mockImplementation(async(_p,_b,_w,fn)=>({acquired:true,value:await fn({batch:async(mutations:unknown[])=>{m.committed.push(mutations);return [];}})}));m.post.mockImplementation(async(_pb,_input,lease)=>{await lease.batch([{collection:'delivery_note_items',method:'create',data:{}}]);return {alreadyPosted:false,complete:false};});});
it('requires authentication before database reads or posting',async()=>{m.user.mockResolvedValue(Response.json({}, {status:401}));expect((await POST(req(),params)).status).toBe(401);expect(m.admin).not.toHaveBeenCalled();expect(m.post).not.toHaveBeenCalled();});
it('denies a site outside user access before acquiring a posting lease',async()=>{m.site.mockReturnValue(Response.json({}, {status:404}));expect((await POST(req(),params)).status).toBe(404);expect(m.lease).not.toHaveBeenCalled();});
it('rejects malformed confirmations and foreign batches without financial writes',async()=>{expect((await POST(req({index:-1}),params)).status).toBe(400);m.admin.mockResolvedValue({collection:()=>({getOne:async()=>({batch:'other'})})});expect((await POST(req(),params)).status).toBe(404);expect(m.post).not.toHaveBeenCalled();});
it('takes a supplier-wide lease covering PO and document identity and passes the authenticated actor',async()=>{expect((await POST(req(),params)).status).toBe(200);expect(m.lease).toHaveBeenCalledWith(expect.anything(),batch,'line-posting',expect.any(Function),{scopeKey:'supplier:supplierabcdefg'});expect(m.post.mock.calls[0][1]).toMatchObject({poId:po,actor:'user'});});
it('returns a conflict for contention or an uncommitted transaction',async()=>{m.lease.mockResolvedValueOnce({acquired:false});expect((await POST(req(),params)).status).toBe(409);m.post.mockRejectedValueOnce(Error('transaction failed'));const response=await POST(req(),params);expect(response.status).toBe(409);expect(await response.json()).toMatchObject({error:'line_not_posted',detail:'transaction failed'});});
it('returns the existing saved receipt on retries',async()=>{m.post.mockResolvedValue({alreadyPosted:true,deliveryNoteId:'receipt'});expect(await (await POST(req(),params)).json()).toMatchObject({alreadyPosted:true,deliveryNoteId:'receipt'});});
it('acquires the shared PO lease before browser receipt posting',async()=>{
expect((await POST(req(),params)).status).toBe(200);
expect(m.poLease).toHaveBeenCalledWith(expect.anything(),'po:'+po,'user',expect.any(Function));
});
it('does not post while a PO edit owns the shared lease',async()=>{
m.poLease.mockResolvedValue({acquired:false});expect((await POST(req(),params)).status).toBe(409);expect(m.post).not.toHaveBeenCalled();
});
it('commits the PO lease fence in the same batch as receipt rows',async()=>{
expect((await POST(req(),params)).status).toBe(200);
expect(m.committed[0]).toEqual([
{collection:'mcp_resource_locks',method:'update',id:'lock',data:{}},
{collection:'delivery_note_items',method:'create',data:{}},
]);
});

View file

@ -2,6 +2,7 @@ import {NextResponse} from 'next/server';
import {pbAdmin} from '@/lib/pb';
import {requireApiUser,requireSiteAccess} from '@/lib/auth/guard';
import {withBatchLease} from '@/lib/dn-pipeline/lock';
import {withResourceLease} from '@/lib/purchasing/resource-lease';
import {postLineWithinLease,PostLineSchema} from '@/lib/dn-pipeline/post-line';
export const dynamic='force-dynamic';export const runtime='nodejs';
export async function POST(request:Request,{params}:{params:Promise<{id:string;noteId:string}>}){
@ -15,7 +16,11 @@ export async function POST(request:Request,{params}:{params:Promise<{id:string;n
if(!note.supplier)return NextResponse.json({error:'supplier_missing'},{status:409});
if(!note.purchase_order)return NextResponse.json({error:'purchase_order_missing'},{status:409});
try{
const result=await withBatchLease(pb,id,'line-posting',lease=>postLineWithinLease(pb,{batchId:id,noteId,poId:note.purchase_order,actor:user.id,body:parsed.data},lease),{scopeKey:'supplier:'+note.supplier});
const poResult=await withResourceLease(pb,'po:'+note.purchase_order,user.id,async poLease=>
withBatchLease(pb,id,'line-posting',lease=>postLineWithinLease(pb,{batchId:id,noteId,poId:note.purchase_order,actor:user.id,body:parsed.data},{...lease,batch:mutations=>lease.batch([poLease.fence(),...mutations])}),{scopeKey:'supplier:'+note.supplier})
);
if(!poResult.acquired)return NextResponse.json({error:'posting_locked',detail:'This purchase order is being updated. Try again shortly.'},{status:409});
const result=poResult.value;
return result.acquired?NextResponse.json(result.value):NextResponse.json({error:'posting_locked',detail:'This batch or purchase order is being updated. Try again shortly.'},{status:409});
}catch(e){return NextResponse.json({error:'line_not_posted',detail:e instanceof Error?e.message:'Posting failed; no success was recorded.'},{status:409});}
}

View file

@ -37,6 +37,7 @@ vi.mock("@/lib/pb", async () => {
};
});
vi.mock("@/lib/purchasing/web-lease",()=>({withWebPurchasingLease:(_pb:unknown,_key:string,fn:(pb:unknown)=>unknown)=>fn(_pb)}));
vi.mock("@/lib/dn-pipeline/missing-purchase-orders",()=>({queueWaitingDeliveries:vi.fn(async()=>0)}));
import { queueWaitingDeliveries } from "@/lib/dn-pipeline/missing-purchase-orders";
import { POST } from "./route";
@ -497,4 +498,11 @@ describe("POST /api/purchase-orders/process-batch", () => {
expect(updateMock).toHaveBeenCalledWith(ID,expect.objectContaining({processing_status:"failed",raw_ocr_data:expect.objectContaining({mcp_managed:true,duplicate_of:"po0000000000002"})}));
});
it.each(['quotation','purchase_order'])('retains trusted MCP provenance through %s extraction',async kind=>{
getOneMock.mockResolvedValue({id:ID,processing_status:'processing',raw_ocr_data:{mcp_managed:true,source:'mcp-upload',file_hash:'original'}});
getFullListMock.mockResolvedValue([]);
await POST(postWithValidAuth({purchase_order_id:ID,extracted:validExtraction('PO-1001',{document_kind:kind,document_title:kind==='quotation'?'QUOTATION':'PURCHASE ORDER'})}));
expect(updateMock.mock.calls[0][1].raw_ocr_data).toMatchObject({mcp_managed:true,source:'mcp-upload',file_hash:'original'});
});
});

View file

@ -1,3 +1,4 @@
import { withWebPurchasingLease } from "@/lib/purchasing/web-lease";
import { NextResponse } from "next/server";
import { queueWaitingDeliveries } from "@/lib/dn-pipeline/missing-purchase-orders";
import { pbAdmin } from "@/lib/pb";
@ -95,7 +96,8 @@ export async function POST(request: Request) {
}
const purchaseOrderId = body.purchase_order_id;
const pb = await pbAdmin();
const basePb = await pbAdmin();
return withWebPurchasingLease(basePb,`po:${purchaseOrderId}`,async pb=>{
// 3. Look up the placeholder PO row (must be 'processing' — n8n claims
// it before extraction, same state machine data_import_batches used).
@ -186,6 +188,7 @@ export async function POST(request: Request) {
processing_status: "failed",
last_error: lastError,
raw_ocr_data: {
...po.raw_ocr_data,
...extracted,
detected_document_kind: refusal.kind,
detected_document_evidence: refusal.evidence,
@ -284,6 +287,7 @@ export async function POST(request: Request) {
processing_status: "processed",
last_error: "",
raw_ocr_data: {
...po.raw_ocr_data,
...extracted,
// The OCR's guess at the project reference. It is a guess, so it
// lives with the other extraction output, not in a schema column
@ -351,6 +355,7 @@ export async function POST(request: Request) {
purchase_order_id: purchaseOrderId,
purchase_order_number: extracted.purchase_order_number,
});
});
}
function validateExtracted(input: unknown): ValidationResult {

View file

@ -37,3 +37,13 @@ it('refreshes an expired preset token using server credentials',async()=>{
const ctx=await createPurchasingContext(principal);
expect(response).toHaveBeenCalledTimes(1);expect(ctx.pb.authStore.token).toBe('refreshed');
});
it('blocks multipart batch source replacements before fetch',async()=>{
const pb=new PocketBase('https://pb.test');guardPurchasingClient(pb,principal);
const fetcher=vi.spyOn(globalThis,'fetch');
const body=new FormData();
body.set('@jsonPayload',JSON.stringify({requests:[{method:'PATCH',url:'/api/collections/dn_batch_sources/records/abcdefghijklmn1',body:{}}]}));
body.set('requests.0.file',new File(['changed'],'replacement.pdf'));
await expect(pb.send('/api/batch',{method:'POST',body})).rejects.toThrow();
expect(fetcher).not.toHaveBeenCalled();
});

View file

@ -41,10 +41,15 @@ export function guardPurchasingClient(pb:PocketBase,principal:Principal):void {
if(path==='/api/batch'){
const body=objectBody(options.body);const requests=body.requests;
if(!Array.isArray(requests)||requests.length>50)throw new PurchasingError('invalid_batch');
for(const raw of requests){
for(const [index,raw] of requests.entries()){
const item=raw as {url?:unknown;method?:unknown;body?:unknown;json?:unknown};
if(typeof item.url!=='string'||typeof item.method!=='string')throw new PurchasingError('invalid_batch');
validate(item.url,item.method,item.json??item.body);
const payload={...objectBody(item.json??item.body)};
for(const [key,value] of Object.entries(body)){
const part=/^requests\.(\d+)\.(.+)$/.exec(key);
if(part&&Number(part[1])===index)payload[part[2]]=value;
}
validate(item.url,item.method,payload);
}
}else validate(url,method,options.body);
return {url,options};

View file

@ -16,7 +16,7 @@ export async function postDeliveryLine(ctx:PurchasingContext,input:Record<string
if(!note.purchase_order||!note.supplier)throw new PurchasingError('candidate_entities_unresolved');
return runOperation(ctx,'post_delivery_line',args,async lease=>{
return {...await postLineWithinLease(ctx.pb,{batchId:args.batchId,noteId:args.id,poId:note.purchase_order,actor:ctx.principal.id,body:args},lease)};
},`batch:${args.batchId}`,{scopeKey:`supplier:${note.supplier}`});
},`batch:${args.batchId}`,{scopeKey:`supplier:${note.supplier}`,resourceKey:`po:${note.purchase_order}`});
}
export async function correctDeliveryReceipt(ctx:PurchasingContext,input:Record<string,unknown>):Promise<OperationResult>{
const args=ReceiptCorrectionSchema.parse(input);

View file

@ -29,3 +29,11 @@ it('retries failed processing without creating another job or receipt',async()=>
expect([...store('mcp_jobs').values()][0].status).toBe('queued');
expect(store('mcp_jobs').size).toBe(1);expect(store('delivery_notes').size).toBe(0);
});
it('terminates a crashed third attempt after reclaiming its lease without a fourth provider call',async()=>{
const {pb,store}=memoryDatabase();await pb.collection('mcp_operations').create({id:'abcdefghijklmn1'});await pb.collection('mcp_jobs').create({id:'abcdefghijklmn2',operation:'abcdefghijklmn1',batch:'abcdefghijklmn3',status:'running',attempts:3});
const scheduler={pb,principal:{id:'scheduler',permissions:new Set<'process'>(['process'])}};
expect(await tickMcpJobs(scheduler,{limit:1,deadlineMs:1000})).toEqual({claimed:1,completed:0,failed:1});
expect(store('mcp_jobs').get('abcdefghijklmn2')?.status).toBe('failed');expect(staged).not.toHaveBeenCalled();
expect(store('mcp_operations').get('abcdefghijklmn1')?.result).toMatchObject({status:'failed',error:'final_attempt_interrupted'});
});

View file

@ -21,14 +21,21 @@ export async function tickMcpJobs(ctx:PurchasingContext,options:{limit:number;de
if(!process.env.MINIMAX_API_KEY)throw new PurchasingError('processing_setup_required',503);
const limit=z.number().int().min(1).max(3).parse(options.limit),deadlineMs=z.number().int().min(1).max(240000).parse(options.deadlineMs);
const deadlineAt=Date.now()+deadlineMs;
const jobs=await ctx.pb.collection('mcp_jobs').getList(1,limit,{filter:ctx.pb.filter('(status="queued" || status="running") && attempts<3 && (next_attempt_at="" || next_attempt_at<={:now})',{now:new Date().toISOString()}),sort:'next_attempt_at,id'});
const jobs=await ctx.pb.collection('mcp_jobs').getList(1,limit,{filter:ctx.pb.filter('((status="queued" && attempts<3) || status="running") && (next_attempt_at="" || next_attempt_at<={:now})',{now:new Date().toISOString()}),sort:'next_attempt_at,id'});
const counts={claimed:0,completed:0,failed:0};
for(const job of jobs.items){
if(Date.now()>=deadlineAt)break;
const result=await withResourceLease(ctx.pb,`job:${job.id}`,ctx.principal.id,async lease=>{
const current=await ctx.pb.collection('mcp_jobs').getOne(job.id);
if(!['queued','running'].includes(current.status)||Number(current.attempts)>=3||current.next_attempt_at&&Date.parse(current.next_attempt_at)>Date.now())return;
counts.claimed++;const attempts=Number(current.attempts??0)+1;
if(!['queued','running'].includes(current.status)||current.next_attempt_at&&Date.parse(current.next_attempt_at)>Date.now())return;
counts.claimed++;
if(Number(current.attempts)>=3){
await lease.batch([
{collection:'mcp_jobs',method:'update',id:job.id,data:{status:'failed',error_code:'final_attempt_interrupted'}},
{collection:'mcp_operations',method:'update',id:current.operation,data:{result:{jobId:job.id,status:'failed',attempts:Number(current.attempts),error:'final_attempt_interrupted'}}},
]);counts.failed++;return;
}
const attempts=Number(current.attempts??0)+1;
await lease.update('mcp_jobs',job.id,{status:'running',attempts});
try{
const summary=await runStagedBatch(ctx.pb,current.batch,{deadlineAt});

View file

@ -29,5 +29,27 @@ it('rolls back the business transaction when a later mutation fails',async()=>{
await lease.update('purchase_orders','missing',{site:'site'});return {};
})).rejects.toThrow();
expect(store('purchase_orders').size).toBe(0);
expect([...store('mcp_operations').values()][0].status).toBe('uncertain');
expect([...store('mcp_operations').values()][0].status).toBe('failed');
});
it('retries after a provider fails before any business batch is submitted',async()=>{
const {pb,store}=memoryDatabase();const ctx={pb,principal:{id:'agent',permissions:new Set<'read'|'write'>(['read','write'])}};
const input={operationId:'83f33948-63c7-4a02-a45a-9ab18d3bdc02'};
await expect(runOperation(ctx,'process',input,async lease=>{await lease.create('purchase_orders',{id:'abcdefghijklmn1'});throw Error('provider unavailable');})).rejects.toThrow('provider unavailable');
expect(store('purchase_orders').size).toBe(0);expect([...store('mcp_operations').values()][0].status).toBe('failed');
expect((await runOperation(ctx,'process',input,async lease=>{await lease.create('purchase_orders',{id:'abcdefghijklmn1'});return {ok:true};})).status).toBe('committed');
});
it('posting excludes PO edits and fences PO ownership in the receipt transaction',async()=>{
const {pb,store}=memoryDatabase();await pb.collection('purchase_orders').create({id:'abcdefghijklmn1',is_verified:false,processing_status:'processed'});
const ctx={pb,principal:{id:'agent',permissions:new Set<'read'|'write'>(['read','write'])}};
const {mutatePurchaseOrder}=await import('./purchase-orders');
const input={operationId:'83f33948-63c7-4a02-a45a-9ab18d3bdc02'};
let excluded=false;
await expect(runOperation(ctx,'post',input,async lease=>{
try{await mutatePurchaseOrder(ctx,'update_purchase_order',{operationId:'83f33948-63c7-4a02-a45a-9ab18d3bdc03',id:'abcdefghijklmn1',ordered_by:'concurrent'});}catch(e){excluded=e instanceof Error&&e.message==='resource_locked';}
await lease.create('delivery_notes',{id:'abcdefghijklmn2'});
const poLock=[...store('mcp_resource_locks').values()].find(r=>r.resource_key==='po:abcdefghijklmn1');
if(!poLock)throw Error('missing PO fence');await pb.collection('mcp_resource_locks').delete(String(poLock.id));return {};
},'batch:abcdefghijklmn3',{resourceKey:'po:abcdefghijklmn1'})).rejects.toThrow();
expect(excluded).toBe(true);expect(store('delivery_notes').size).toBe(0);
});

View file

@ -16,8 +16,14 @@ export async function getOperation(ctx:PurchasingContext,operationId:string):Pro
if(rows.length!==1)throw new PurchasingError(rows.length?'operation_id_ambiguous':'operation_not_found',404);
return {operationId,status:rows[0].status,result:rows[0].result??null};
}
export async function runOperation(ctx:PurchasingContext,tool:string,input:MutationInput & Record<string,unknown>,commit:(lease:BatchLeaseContext)=>Promise<Record<string,unknown>>,resourceKey?:string,options?:{scopeKey?:string}):Promise<OperationResult>{
export async function runOperation(ctx:PurchasingContext,tool:string,input:MutationInput & Record<string,unknown>,commit:(lease:BatchLeaseContext)=>Promise<Record<string,unknown>>,resourceKey?:string,options?:{scopeKey?:string;resourceKey?:string;commitFence?:BatchMutation}):Promise<OperationResult>{
MutationSchema.parse(input);
if(options?.resourceKey){
const {resourceKey:extraKey,...rest}=options;
const outer=await withResourceLease(ctx.pb,extraKey,ctx.principal.id,lease=>runOperation(ctx,tool,input,commit,resourceKey,{...rest,commitFence:lease.fence()}));
if(!outer.acquired)throw new PurchasingError('resource_locked',409);
return outer.value;
}
const id=operationRecordId(ctx.principal.id,tool,input.operationId),payloadHash=operationPayloadHash(tool,input);
const acquire=resourceKey?.startsWith('batch:')?withBatchLease:withResourceLease;
const key=resourceKey?.startsWith('batch:')?resourceKey.slice(6):resourceKey??`operation:${id}`;
@ -35,7 +41,7 @@ export async function runOperation(ctx:PurchasingContext,tool:string,input:Mutat
const pending:BatchMutation[]=[];
const buffered:BatchLeaseContext={...lease,
batch:async mutations=>{
if(pending.length+mutations.length>48)throw new PurchasingError('transaction_too_large');
if(pending.length+mutations.length>(options?.commitFence?47:48))throw new PurchasingError('transaction_too_large');
for(const mutation of mutations){if(mutation.method==='delete')throw new PurchasingError('business_delete_forbidden',403);pending.push(mutation);}
return mutations.map(m=>({...(m.method!=='delete'&&!(m.data instanceof FormData)?m.data:{}),id:m.method==='create'?String((m.data as Record<string,unknown>).id??''):m.id}) as MutationRecord);
},
@ -43,15 +49,17 @@ export async function runOperation(ctx:PurchasingContext,tool:string,input:Mutat
update:async(collection,recordId,data)=>(await buffered.batch([{collection,method:'update',id:recordId,data}]))[0]!,
delete:async()=>{throw new PurchasingError('business_delete_forbidden',403);},
};
let submitted=false;
try{
const value=await commit(buffered);
await lease.batch([...pending,{collection:'mcp_operations',method:'update',id,data:{status:'committed',result:value}}]);
submitted=true;
await lease.batch([...(options?.commitFence?[options.commitFence]:[]),...pending,{collection:'mcp_operations',method:'update',id,data:{status:'committed',result:value}}]);
return {operationId:input.operationId,status:'committed' as const,result:value};
}catch(error){
// A lost response may hide a successful atomic commit. Preserve committed state if readable.
const saved=await ctx.pb.collection('mcp_operations').getOne(id).catch(()=>null);
if(saved?.status==='committed')return {operationId:input.operationId,status:'committed' as const,result:saved.result??null};
await lease.update('mcp_operations',id,{status:pending.length?'uncertain':'failed',error_code:error instanceof PurchasingError?error.code:'operation_failed'}).catch(()=>undefined);
await lease.update('mcp_operations',id,{status:submitted&&![400,401,403,409,422].includes(Number((error as {status?:number})?.status))?'uncertain':'failed',error_code:error instanceof PurchasingError?error.code:'operation_failed'}).catch(()=>undefined);
throw error;
}
},options);

View file

@ -33,3 +33,10 @@ it('serializes MCP edits with an active browser PO edit',async()=>{
await expect(mutatePurchaseOrder(ctx,'add_purchase_order_item',{operationId:op,id:'abcdefghijklmn1',item:{description:'pipe',qty:1,rate:1}})).rejects.toThrow('resource_locked');
release();expect((await web).status).toBe(200);
});
it.each(['pending','processing','failed'])('refuses editing or verification during incomplete %s extraction',async processing_status=>{
const {pb}=memoryDatabase();await pb.collection('purchase_orders').create({id:'abcdefghijklmn1',is_verified:false,processing_status});
const ctx={pb,principal:{id:'agent',permissions:new Set<'read'|'write'>(['read','write'])}};
await expect(mutatePurchaseOrder(ctx,'update_purchase_order',{operationId:op,id:'abcdefghijklmn1',ordered_by:'manual'})).rejects.toThrow('po_processing_incomplete');
await expect(mutatePurchaseOrder(ctx,'set_purchase_order_verification',{operationId:'83f33948-63c7-4a02-a45a-9ab18d3bdc03',id:'abcdefghijklmn1',verified:true})).rejects.toThrow('po_processing_incomplete');
});

View file

@ -16,7 +16,8 @@ export const VerifyPurchaseOrderSchema=MutationSchema.extend({id:RecordId,verifi
export type PurchaseOrderAction='create_purchase_order'|'update_purchase_order'|'add_purchase_order_item'|'update_purchase_order_item'|'set_purchase_order_verification';
const schemas={create_purchase_order:CreatePurchaseOrderSchema,update_purchase_order:UpdatePurchaseOrderSchema,add_purchase_order_item:AddPurchaseOrderItemSchema,update_purchase_order_item:UpdatePurchaseOrderItemSchema,set_purchase_order_verification:VerifyPurchaseOrderSchema};
export async function requireEditableOrder(pb:PurchasingContext['pb'],id:string):Promise<RecordModel>{
const po=await pb.collection('purchase_orders').getOne(id,{fields:'id,site,supplier_ref,is_verified,purchase_order_number,required_by_date,ordered_by,total_value,items_count'});
const po=await pb.collection('purchase_orders').getOne(id,{fields:'id,site,supplier_ref,processing_status,is_verified,purchase_order_number,required_by_date,ordered_by,total_value,items_count'});
if(['pending','processing','failed'].includes(po.processing_status))throw new PurchasingError('po_processing_incomplete',409);
if(po.is_verified)throw new PurchasingError('po_verified',409);return po;
}
export function itemFields(input:unknown):PurchaseOrderItemPayload & {amount:number}{
@ -44,7 +45,8 @@ async function performOrderMutation(ctx:PurchasingContext,lease:BatchLeaseContex
return {id,itemIds,...totals};
}
if(action==='set_purchase_order_verification'){
const input=VerifyPurchaseOrderSchema.parse(args);const before=await pb.collection('purchase_orders').getOne(id,{fields:'id,is_verified'});
const input=VerifyPurchaseOrderSchema.parse(args);const before=await pb.collection('purchase_orders').getOne(id,{fields:'id,is_verified,processing_status'});
if(['pending','processing','failed'].includes(before.processing_status))throw new PurchasingError('po_processing_incomplete',409);
await lease.update('purchase_orders',id,{is_verified:input.verified});return {id,is_verified:input.verified,before:{is_verified:before.is_verified}};
}
const po=await requireEditableOrder(pb,id);

View file

@ -10,6 +10,8 @@ type LockRecord = { id?: string; owner?: string; expires_at?: string };
export type LeaseResult<T> = { acquired: true; value: T } | { acquired: false };
export type BatchLeaseContext = {
/** Include this acquisition in a transaction also fenced by another lease. */
fence: () => BatchMutation;
/** Liveness check only. Business writes must use the fenced helpers below. */
assertOwned: () => Promise<void>;
/** Renew this acquisition immediately; normally the heartbeat does this. */
@ -204,6 +206,7 @@ export async function withResourceLease<T>(
}
};
const context: BatchLeaseContext = {
fence: () => ({ collection: LOCK_COLLECTION, method: "update", id: claimedId, data: { expires_at: new Date(Date.now() + LEASE_MS).toISOString() } }),
assertOwned,
renew,
batch,

View file

@ -36,3 +36,8 @@ describe('MCP authentication', () => {
expect(() => requirePermission(principal,'write')).toThrow('permission_denied');
});
});
it('accepts the configured Host when Next uses an internal request URL',()=>{
expect(authenticateMcp(new Request('http://localhost:3000/api/mcp',{headers:{Host:'mega.test',Authorization:`Bearer ${token}`}})).id).toBe('test');
expect(()=>authenticateMcp(request({Host:'evil.test',Authorization:`Bearer ${token}`}))).toThrow('host_forbidden');
});

View file

@ -28,7 +28,9 @@ export function authenticateMcp(request:Request):Principal {
} catch {throw new McpHttpError(503,'mcp_setup_required');}
const requestUrl=new URL(request.url);
const host=request.headers.get('host');
if(requestUrl.host!==publicUrl.host||(host&&host!==publicUrl.host))throw new McpHttpError(403,'host_forbidden');
// Next.js may construct request.url with the internal listener hostname.
// Validate the actual HTTP Host; never accept forwarded-host overrides.
if((host??requestUrl.host)!==publicUrl.host)throw new McpHttpError(403,'host_forbidden');
const origin=request.headers.get('origin');
if(origin&&!origins.includes(origin))throw new McpHttpError(403,'origin_forbidden');
const match=/^Bearer ([^\s]{16,512})$/i.exec(request.headers.get('authorization')||'');