Skip to main content

Reporting MCP

An owner, CFO, or principal connects ChatGPT or Claude to their own portfolio statistics and analyses them conversationally. Read-only by construction.

Endpoint & transport

  • POST /api/v1/mcp (Bearer token — OAuth, recommended) and POST /api/v1/mcp/{key} (key-in-URL fallback) — streamable HTTP, stateless: each POST is one JSON-RPC message answered with plain JSON (no SSE, no sessions). GET returns 405.
  • Methods: initialize (echoes the client's protocol version; 2025-06-18 default), notifications/initialized (202), ping, tools/list, tools/call. Anything else → -32601. Introspection methods (initialize/initialized/ping/tools/list) are allowed without auth — they reveal only public tool schemas; tools/call always requires a credential.

OAuth 2.1 (jobctl-shaped)

Full authorization-server flow so ChatGPT/Claude onboard via standard OAuth instead of key copy-paste:

  • Discovery: /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server (RFC 8414); a 401 from /api/v1/mcp carries WWW-Authenticate: Bearer resource_metadata=….
  • Dynamic client registration (RFC 7591): POST /oauth/register — public clients only (token_endpoint_auth_method=none), redirect URIs must be https (or http on localhost), max 10.
  • Authorize: GET /oauth/authorize — requires an agency-admin session (redirects to /login?next=… otherwise) and renders a server-side consent screen naming the client and the agency. PKCE S256 only; plain is rejected. Validation errors render inline — never redirected to an unvalidated redirect_uri.
  • Token: POST /oauth/token (form-encoded) — authorization_code (code TTL 10 min, single-use, exact redirect + PKCE binding) and refresh_token (TTL 30 d, rotation revokes the old refresh token AND its paired access token). Access tokens are ordinary reporting_api_keys rows with a 1-hour expiry (prefix pmf_), so bearer auth, revocation, and last-used tracking are shared with manual keys. Prefixes: pmf_ access, pmfr_ refresh, pmfc_ code, pmfclient_ client id — every secret stored SHA-256-only.
  • Rate limits: oauth-* 60/h + 300/d per IP on the whole /oauth/* surface, on top of the mcp-* caps.

The key-in-URL flavour stays for clients that can't do OAuth; it is a 256-bit secret — the URL is the credential.

Key security (ported from jobctl)

PracticeImplementation
Generationpmf_ + 32 random bytes base64url (SecureRandomTokenGenerator)
StorageSHA-256 hex only, global UNIQUE index (reporting_api_keys, migration V39)
LookupIndexed equality on the hash — no secret comparison exists, so no constant-time concerns
DisplayPlaintext returned exactly once at creation; UI lists only preview ("pmf_8f92…a1b2")
RevocationSoft revoked_at; revoked and invalid keys are indistinguishable (-32001)
Last usedThrottled fire-and-forget update, at most once per minute
Channel separationKey management (/api/v1/reporting-keys) is session + admin only — a leaked MCP key can never mint or revoke keys
RLSThe table itself has no RLS (crypto-scoped, same rationale as magic_link_tokens in V4); the row carries agency_id, AuthenticateReportingKey binds TenantContext, and every read the tools perform then goes through RLS
Rate limitsmcp-hourly 300/h + mcp-daily 2000/day per IP on the endpoint itself (jobctl's one gap, fixed here)
Errors5xx-class failures log server-side and return a detail-free -32603

Read-only enforcement

Structural, not advisory: McpController can only reach AccountReporting, which is constructed exclusively from repository read methods and read use cases — there is no write path to expose. Tool annotations also declare readOnlyHint: true for client UIs.

Privacy

Outputs carry aggregates and property addresses — never tenant names, emails, or phone numbers. Same data-minimisation stance as the AI features.

Tools

ToolArgsReturns
portfolio_overviewproperty/lease counts, SLA picture, compliance counts, currency, Aqarat expiry (QA)
overdue_complianceincludeDueSoon?overdue obligations with property, category, due date, days overdue (max 200 rows)
maintenance_statswindowDays? (90)request counts by urgency/category/status + live SLA dashboard
sla_breachesdrill-down: every open request past its SLA — request id, property, issue excerpt, deadline, hours overdue (no tenant identity)
rent_rollper-property contractual rent normalised to monthly + monthly/annualised totals (contractual only — no collections/arrears yet)
maintenance_requestsstatus?, urgency?, slaStatus?, windowDays?individual requests, filterable, newest first
work_ordersstatus?individual work orders + per-status counts: contractor, source, timestamps, ceiling/final cost
compliance_obligationsstatus?every obligation by effective status (broader than overdue_compliance)
property_detailsproperty (address fragment)360° view: lease history (with lease ids), open maintenance + SLA state, compliance summary + overdue items, recent work orders, service-charge years
lease_detailsleaseIdone lease in full: term, days to end, rent + monthly equivalent, tenant count, contract versions on file, the obligations this lease triggered

Every tool declares an outputSchema for its structuredContent (permissive — extra fields allowed), so validating clients like ChatGPT can type the responses; field descriptions double as inline docs for the model. Lease-triggered obligations (registration, deposit return) carry a lease label ("2025-07-17 → 2026-07-10 (ENDED)") — the data model links Property → Lease → Obligation (migration V41, existing rows backfilled best-effort by trigger arithmetic), so a deposit-return task next to an active lease reads as what it is: the previous tenancy's clock.

The intended interaction chain is overview → detect → drill down → context: portfolio_overview says "3 requests past SLA"sla_breaches names them → property_details / lease_details give the full picture of the affected property or tenancy.

What an AI analyst actually finds (field notes)

The first two real analysis sessions (ChatGPT connected to the Doha demo) produced findings we hadn't asked for:

  • Renewal-concentration risk: from lease end dates alone it computed that 100% of the rent roll (QAR 79,000/month) expires between April and July 2027 in two waves — and recommended starting renewal workflows at 120–150 days, not 90. No tool computes this; the AI derived it from lease_health.
  • A data-model gap it argued for: a deposit-return task on a property with an active lease looked wrong until obligations learned which lease they belong to (now shipped as V41 — the task belonged to the previous tenancy).
  • Cross-checked the maths: its hand-summed rent roll matched rent_roll to the riyal.

This is the pitch in one line: the connector turns portfolio data into an analyst that reads everything and tells you what you didn't ask.

SLA semantics (deliberate): sla_breaches measures the request-processing SLA — submission until the request is actioned or converted to a work order. It is not end-to-end resolution: a converted case lives on as a work order, and long-running ones surface through work_orders plus the attentionRequired "work orders in flight >14 days" line. Read the two together for the full unresolved picture.

Connector discovery quirk (client-side): the server serves one tool list (all tools, one source). If a client's Actions panel shows fewer tools than tools/list returns, it cached an old schema registration — remove and re-add the connector.

Status note (don't re-report as missing): Attention Required is already available at the MCP layer via portfolio_overview().attentionRequired. The remaining opportunity is a separate SPA iteration — presenting those signals as a dedicated Attention Inbox with prioritisation, filtering, and links into the relevant records. MCP capability: ✅ done; SPA inbox: 🔜 UI iteration. | work_order_spend | windowDays? (365), groupBy? category|property|month | completed-WO spend in integer cents + counts per group | | service_charges | — | per-property ledger years with YoY change and unusual-increase flags | | lease_health | — | active/future leases, terms, expiring ≤90d, QA contract-versions-on-file with missing-Arabic warning |

Amounts are integer cents in the agency currency (returned alongside). Every tool declares an outputSchema for its structuredContent (permissive — extra fields allowed), so validating clients like ChatGPT can type responses. portfolio_overview also carries an attentionRequired list (expired Aqarat licence, overdue compliance, SLA breaches, untriaged requests), a rent-roll summary, and QA contract-documentation completeness — the ten-second "what needs me now" answer. service_charges reports coverage ("N of M properties have records") so absence reads as not recorded, never as zero.

Setting up a connector

OAuth (recommended): add https://app.pmfriend.com/api/v1/mcp as a custom connector with OAuth — the client discovers the endpoints, registers itself, and opens the PMFriend login + consent screen (agency admin approves). Tokens expire hourly and refresh automatically.

Key-in-URL (fallback): agency admin creates a key in Settings → Reporting API, pastes the /api/v1/mcp/{key} URL with Authentication set to None.

Then ask: "Which properties have overdue compliance?", "Plot work-order spend by month for the last year", "Any lease missing its Arabic contract?"

Revoking keys in Settings kills manual-key connectors immediately; OAuth access tokens die at rotation/expiry (max 1 h) once their refresh chain is gone.