# MCP Server Contracts

**Status:** Draft for review
**Parent doc:** [Proposal_arch.md](Proposal_arch.md) D8
**Scope decision (confirmed):** read-only reach into epay-main and jpos-main is sufficient. **No other team needs to build an MCP server.** All five servers below are built and owned by this repo's team. The only genuine external dependency is jPOS/BEAM metrics export for `infra-mcp` (§1.7 of the parent doc) — that's a metrics-export ask to the Java side, not an MCP-server-build ask, and it's scoped separately at the end of this document.

---

## Ownership model

| Server | Built by | External dependency |
|---|---|---|
| `tms-mcp` | This team, wraps `tms_core` | None |
| `settlement-mcp` | This team, wraps `settlement_core` | None |
| `risk-mcp` | This team, wraps `risk_core` | None |
| `switch-mcp` | This team, wraps `da_product_app`'s existing `PosTransactions` schemas | None — same DB, same umbrella, jPOS never has to do anything |
| `infra-mcp` | This team, wraps Prometheus + AlertManager | jPOS/BEAM metrics export (§1.7) — an endpoint to scrape, not a server to build |

Every tool schema below is grounded in verified fields (from this conversation's direct file reads and the earlier Explore verification pass in [entity-resolution.md](entity-resolution.md)), not invented. Where a field set wasn't directly confirmed, it's marked so explicitly — don't implement against it until someone reads the actual schema.

---

## `tms-mcp`

Wraps: `AutoPushService`, `OtaService`, `AppPackageService`, `RemoteLogService`, `MqttCommandBuilder`, plus terminal-registry reads.
Consumed by: A1 (Fleet Health), A2 (Config Compliance), A3 (Rollout).

### Actuators

| Tool | Risk tier | Params | Wraps |
|---|---|---|---|
| `trigger_config_push` | T1 | `serial_number`, `config_type` (`parameter\|emv_config\|keys_config\|application`) | `AutoPushService.trigger_config_push/4` |
| `trigger_missing_version_push` | T1 | `serial_number` | `AutoPushService.trigger_missing_version_push/3` |
| `regenerate_config_artifact` | T1 | `serial_number`, `config_type` | `ParamsZipBuilder.build_and_store/2` / `L3ConfigZipBuilder.build_and_store/2` |
| `start_remote_log_session` | T1 | `serial_number`, `requested_by` | `RemoteLogService.start_log_session/3` |
| `stop_remote_log_session` | T1 | `session_id` | `RemoteLogService.stop_log_session/1` |
| `send_mqtt_command` | T2 | `serial_number`, `command_type`, `params` | `MqttCommandBuilder` |
| `deploy_app_package` | T2/T3 (tier depends on cohort size — see parent doc §4) | `serial_number`, `package_id`, `config_id` | `AppPackageService.deploy_package_to_device/3` |
| `rollback_app_package` | T3 | `serial_number`, `target_version` | `AppPackageService.rollback_package/2` |

### Reads

| Tool | Params | Returns |
|---|---|---|
| `get_terminal` | `serial_number` | `tms_terminals` row |
| `get_terminal_status_history` | `serial_number`, `from`, `to` (capped, max 90 days) | `tms_terminal_status_logs` (capped 500 rows) |
| `find_terminals` | `filters` (vendor/model/area/merchant/group/tier) | Filtered `tms_terminals` list |
| `get_fleet_snapshot` | `from`, `to`, `group_by` | Online/offline counts bucketed |
| `get_push_history` | `serial_number \| template_id`, `from`, `to` | `parameter_push_logs` + `push_details` |
| `get_push_outcome_stats` | `filters` | Success/fail rates sliced by any dimension |

**Never exposed:** anything touching `KeysConfigService`, RKI, KEK/KCV, slot data — T4 by absence, per the parent doc's tool catalogue.

---

## `settlement-mcp`

Wraps: `ReconciliationEngine`, `SettlementCore.CoreTransactionSync`, `SettlementCore.Mms.*` (the verified merchant-resolution path — [entity-resolution.md](entity-resolution.md) §2.1).
Consumed by: A4 (Settlement Ops), A5 (Risk Triage, for merchant context only), A6 (Investigation).

### The flagship tool

```
trace_transaction(rrn | tid+auth_number | core_transaction_id)
```
The join-path walker (parent doc §1.5/§1.6, investigation doc §3). **Lives here, not in `switch-mcp`**, because it's fundamentally a settlement/dispute-investigation capability — but it queries `pos_transaction` directly via Ecto as its first hop, in-process, without going through `switch-mcp`'s own protocol boundary. (`switch-mcp` still exposes the same live-switch read as a standalone tool — see below — for callers, like A1, that only need the switch hop and nothing downstream.)

Walks: `pos_transaction` (live, first) → `switch_dump_records`/`qr_scheme_dump_records` (EOD fallback) → `core_transactions` → `settlement_mis_items` → `payout_items` → `payout_batches`, plus any `reconciliation_exceptions` row. Returns each hop as `:present | :absent | :mismatched`. Deterministic Elixir, not model reasoning — see investigation doc §3 for why.

### Other reads

| Tool | Params | Returns |
|---|---|---|
| `get_settlement_timeline` | `date` | Dump arrival → recon → MIS → approval |
| `get_recon_exceptions` | `date`, `filters` | `reconciliation_exceptions` (capped) |
| `get_exception_clusters` | `from`, `to` | Grouped by type/merchant/dump_source |
| `get_sla_checkpoint_history` | `from`, `to` | `SlaMonitorWorker` checkpoint results |
| `get_merchant_settlement_summary` | `mid`, `date_range` | What the merchant should have received |
| `get_merchant_risk_profile` | `mid \| merchant_reference_number` | `SettlementCore.Mms.MerchantMetadata` (table `user_metadata`) + `MmsUser` (table `users`) — resolved via the existing `CoreTransactionSync.get_merchant_metadata/1`, not a new query |
| `get_kyc_status` | `mid` | `SettlementCore.Mms.KycRequest` (table `kyc_requests`) — completion %, per-method approval status |
| `get_merchant_mdr_profile` | `mid` | `MdrTemplate`/`MdrRate`/`MccMdrRate`/`Mcc`/`CardType` — active MDR, MCC risk tier |

### Actuators

| Tool | Risk tier | Params | Wraps |
|---|---|---|---|
| `re_run_failed_job` | T1 | `oban_job_id` | Oban retry |
| `retrigger_reconciliation` | T2 | `date` | `ReconciliationEngine.run/1` (re-run) |
| `hold_transaction` | T2 | `core_transaction_id`, `exception_type` | `ReconciliationEngine.hold_transaction/2` — reversible by a human release, not itself the disposition |

**Never exposed:** `ReconciliationEngine.release_transaction/1` — T4, human-only financial disposition, per the parent doc's hard constraint. No tool wraps it, full stop.

---

## `risk-mcp`

Wraps: `risk_core` (`RiskRuleHit`, `RiskAuditLog`).
Consumed by: A5 (Risk Triage), A6 (Investigation, read-only).

| Tool | Risk tier | Params | Returns / Wraps |
|---|---|---|---|
| `get_risk_hits` | read | `filters` | `risk_rule_hits` (`transaction_id`, `transaction_type`, `merchant_id`, `category`, `status`, `triggered_at`, `supervisor_id`, `action_taken`, `action_at`, `notes`, `metadata`) — **REDACTED at the tool boundary**, no PAN/track data ever leaves this tool |
| `get_risk_rule_stats` | read | `rule_id`, `from`, `to` | Hit volume + disposition breakdown |
| `enrich_risk_hit` | T1 | `hit_id`, `evidence` | Adds `notes`/`metadata` only — never touches `status` or `action_taken` |
| `categorize_risk_hit` | T1 | `hit_id`, `proposed_category` | A recommendation field, not a disposition — a human supervisor still sets `action_taken` |

**Never exposed:** anything that sets `status` to a final disposition or writes `action_taken`. Per the parent doc's A5 hard constraint — release/hold decisions on a risk hit are human-only, always.

---

## `switch-mcp`

Wraps: `DaProductApp.PosTransactions.PosTransaction`, `.PosTransactionReversal` (both verified — fields below), plus `PosFailedTransaction`/`PosTempTransaction` (named in the RFP doc, **fields not yet directly verified — read the actual schema files before implementing these two**).
Consumed by: A1 (terminal transaction activity), A6 (standalone switch-hop lookups; `trace_transaction` in `settlement-mcp` queries the same table directly rather than hopping through this server internally).

| Tool | Params | Returns |
|---|---|---|
| `get_switch_transaction` | `rrn \| stan \| tid` | `pos_transaction`: `s_tid_stan`, `reference_no`, `acquirer_reference_no`, `scheme_reference_no`, `approval_code`, `response_code`, `masked_card_no`, `mcc_code`, `total_amount`, `mti`, `proc_code` |
| `get_switch_transaction_reversal` | `original_temp_txn_id \| reversal_reference_no` | `pos_transaction_reversal`: `reversal_status`, `reversal_reason`, `retry_count`, `max_retry_attempts`, `original_amount`, `original_stan`, `original_time`/`original_date`, `error_message` |
| `get_switch_failed_transaction` | `rrn \| stan \| tid` | `pos_failed_transaction` — **verify schema before implementing; not directly read in this design pass** |
| `get_terminal_transaction_activity` | `serial_number`, `from`, `to` | Count/volume of `pos_transaction` rows for a terminal — useful for A1 correlating "is this terminal actually processing" against heartbeat state |

**Redaction, mandatory:** `encrypted_pan`, `hash_pan`, `encrypted_track2`, `encrypted_expiry`, `original_encrypted_pan`, `original_encrypted_expiry_date` are **never** returned by any tool here. Only `masked_card_no`.

**A safety nuance worth calling out explicitly, because it's weaker than `settlement-mcp`'s MMS boundary:** `DaProductApp.Repo` (which owns these tables) is a normal read/write Ecto repo — **not** configured `read_only: true` the way `ShukriaMmsRepo` is (verified in [entity-resolution.md](entity-resolution.md) §2.1). `switch-mcp`'s read-only-ness is enforced entirely by *tool catalogue discipline* — no write tool is defined, full stop — with no driver-level backstop underneath it. This is exactly the kind of boundary `AgentCore.ReadTool`'s registration-time check exists for (parent doc §1, investigation doc §6), and it deserves more scrutiny in code review than `settlement-mcp`'s MMS tools, precisely because the database itself won't save you if a write tool is accidentally defined here.

---

## `infra-mcp`

Wraps: Prometheus query API + AlertManager API — built once §1.7's export work lands (in-repo BEAM `prom_ex` export first; jPOS/network export is the external dependency, tracked separately, not blocking this server's BEAM-only capability).
Consumed by: A8 (Infrastructure & Switch Health) only.

| Tool | Params | Returns |
|---|---|---|
| `get_beam_health` | `node`, `from`, `to` | `vm.memory.total`, `vm.total_run_queue_lengths.*` trend, once `prom_ex` is wired (parent doc §9) |
| `get_jpos_health` | `from`, `to` | **Pending jPOS export** — Q2 sysmon metrics, `ReversalMetrics` backlog, if/when exposed |
| `get_acquirer_connectivity` | `acquirer`, `from`, `to` | **Pending jPOS export** — YSP/Fiserv channel state, latency, if/when exposed |
| `get_active_alerts` | `filters` | Current AlertManager alerts |
| `get_alert_history` | `from`, `to` | Resolved/firing alert history |

Degrades gracefully: if jPOS/network export never lands, `get_jpos_health` and `get_acquirer_connectivity` simply have nothing to return — A8 still gets full value from BEAM health alone, per the parent doc's explicit design goal that the rest of the system must not depend on this export landing.

---

## MCP library spike outcome (Phase 0)

Checked hex.pm for the current state of the Elixir MCP ecosystem rather than guessing:

| Library | Version | All-time downloads | Last published | Notes |
|---|---|---|---|---|
| **`hermes_mcp`** | 0.14.1 | 185,812 | Aug 2025 | Unified client + server, explicit Phoenix integration — by far the most adopted. Hasn't published in ~1 year as of this check. |
| **`conduit_mcp`** | 0.9.7 | 2,348 | Jun 2026 | Server-focused, Streamable HTTP + SSE transports, built-in auth/CORS. Actively maintained, much smaller adoption. |
| `phantom_mcp`, `mcp`, `elixir_mcp_server`, `anubis_mcp`, `backplane_mcp_protocol` | — | — | — | Surfaced in the same search; not evaluated further given the gap in adoption/maturity vs. the two above. |

**Recommendation:** trial `hermes_mcp` first — the adoption gap (80x) is a real signal of production battle-testing, and Phoenix integration matches this codebase's stack directly. If its ~1-year publish gap turns out to mean it's missing a current MCP spec feature this design needs (e.g. something in the Streamable HTTP transport), `conduit_mcp`'s built-in auth/CORS is the fallback — worth a second look specifically because auth matters more here than in a typical MCP use case, given what these servers expose. This is a build-time trial, not a decision to finalize from documentation alone; confirm against this codebase in the Sprint 0.1 spike (implementation-plan.md §3) before committing.

---

## What this confirms about scope

- **No epay-main MCP server.** Merchant/KYC/risk data reached entirely through `settlement-mcp`'s existing `SettlementCore.Mms.*` path, read-only, no new integration.
- **No jpos-main MCP server.** Switch transaction data reached entirely through `switch-mcp`'s existing `DaProductApp.PosTransactions.*` path, read-only, same database, same umbrella.
- **The one real external ask** is scoped narrowly to a metrics endpoint for `infra-mcp` — see the parent doc §1.7 and §12 item 7 for how to raise that with the jPOS team.
- If write-capable reach into either system is ever wanted later, that's a new design exercise requiring those teams to own their own servers and their own risk model — not an extension of anything specified here.
