---
title: "Hub MCP Ticket Client"
slug: hub-mcp-ticket-client
section: Integrations
status: stable
description: "The Hub (repo `BackOfficePrototype`) contains a client for the **MCP coordination server**, a ticket service that the Hub addresses as portal id `2` (`HUB_PORTAL_ID`)."
last-updated: 2026-10-07
---

# Hub MCP Ticket Client

## Overview

The Hub (repo `BackOfficePrototype`) contains a client for the **MCP coordination server**, a ticket service that the Hub addresses as portal id `2` (`HUB_PORTAL_ID`). The client lives in `server/services/mcpTicketClient.ts`, the base-URL resolver in `server/lib/mcpBaseUrl.ts`, and the staff-facing routes in `server/routes/tickets.ts`.

The client does three things: it files tickets automatically when the Hub detects failures (SEVIS operations, webhook and event dead letters, CBC upload ingestion), it reads and updates tickets for the admin routes, and it runs a startup routine that registers server info and checks inbound tickets.

This is not the Alliance MCP Gateway. The gateway is a separate system with its own user guide: see [MCP Gateway](/docs/mcp-gateway) for contrast. The Hub client talks to the coordination server with an `x-api-key` header, not a gateway PAT. For the Hub's webhook subsystem see [Webhook Event Registry](/docs/webhook-event-registry); for permissions see [RBAC](/docs/rbac).

## Deployment Status

The Kubernetes manifests for development, QA and prod (`k8s/<env>/deployment.yaml`) carry a comment dated 2026-08-26 stating that `MCP_API_KEY` was intentionally removed from the pod environment because the Hub MCP-coordination integration was decommissioned (the endpoint had been unreachable or returning 401). The comment says every consumer no-ops when the key is unset, and that the secret `globus-mcp-api-key` was left in place for a possible rewire to the Alliance Gateway's Bearer-PAT auth. The development and QA comments also say the `MCP_URL` configmap value was left in place; the prod deployment does not define `MCP_URL`.

In the code, `callMcp` returns `null` and logs a warning when `MCP_API_KEY` is empty, so with the key unset no ticket is filed and the admin routes behave as "MCP not configured" (see below). `k8s/<env>/external-secrets.yaml` still lists `MCP_API_KEY` as a synced secret key (from `globus-mcp-api-key`), but the deployment manifests no longer reference it. The `publish-mcp` step in `cloudbuild-dev.yaml`, `cloudbuild-qa.yaml` and `cloudbuild-prod.yaml` still passes `MCP_API_KEY` to `server/scripts/publishMcpEndpoints.ts`.

## Configuration

### Base URL Resolution

`resolveMcpBaseUrl()` in `server/lib/mcpBaseUrl.ts` picks the URL, trimming whitespace and ignoring empty values:

1. `MCP_URL` (wins when both are set)
2. `MCP_BASE_URL` (alias from the MCP server reconnection guide)
3. Default `https://development.mcp.allianceabroad.com/mcp`

The first resolution per process logs which source was used. The client resolves the URL once, at module load.

### Environment Variables

| Variable | Default | Required | Effect |
| --- | --- | --- | --- |
| `MCP_URL` | see above | Optional | MCP server URL, highest precedence |
| `MCP_BASE_URL` | none | Optional | Alias for `MCP_URL` |
| `MCP_API_KEY` | none | Required for any call | Sent as `x-api-key`; read on each call. If empty, calls are skipped and return `null` |
| `TICKET_COOLDOWN_MS` | `900000` (15 min) | Optional | Duplicate-suppression window for `fileTicket` |
| `DEAD_LETTER_COOLDOWN_MS` | `86400000` (24 h) | Optional | Cooldown used by event and webhook dead-letter tickets; also the age after which old entries are pruned from the in-memory map (pruning runs only when the map holds more than 500 entries; permanently suppressed keys are kept) |
| `MCP_CIRCUIT_BREAKER_MS` | `300000` (5 min) | Optional | How long the circuit stays open |
| `MCP_AUTO_ACKNOWLEDGE` | enabled | Optional | Inbound open tickets are auto-acknowledged unless the value is exactly `false` |
| `MCP_BULK_CLEANUP` | off | Optional | When exactly `true`, startup closes open outbound tickets whose titles match known noise patterns |
| `REPL_SLUG`, `REPL_OWNER`, `REPLIT_DEV_DOMAIN` | `globus-hub`, `unknown`, none | Optional | Used only to build the server-info record published at startup |

The three numeric variables are parsed with `parseInt` at module load; a non-numeric value yields `NaN` and is not validated.

## MCP Call Transport

`callMcp` POSTs a JSON-RPC 2.0 `tools/call` request (`jsonrpc`, `id: 1`, `params.name`, `params.arguments`) with headers `Content-Type: application/json`, `Accept: application/json, text/event-stream` and `x-api-key`. The timeout is 15 seconds. The response is parsed from the first `data:` line if present (server-sent events), otherwise as plain JSON. `parseMcpContent` then extracts the first text content item, parsing it as JSON when it starts with a brace or bracket.

Tools called: `submit_ticket`, `get_tickets`, `update_ticket`, `add_ticket_comment`, `publish_server_info`, `get_server_info`. All include the Hub portal id (`from_portal_id` for submit, `portal_id` otherwise).

### Circuit Breaker

- The threshold is fixed at 3 consecutive failures (not configurable). A failure is a non-2xx HTTP response, a timeout, or a fetch `TypeError`.
- When open, calls made without the bypass flag return `null` without a network request. After `MCP_CIRCUIT_BREAKER_MS` the counters reset and calls resume.
- A successful call resets the counter.
- `get_tickets`, `update_ticket`, `add_ticket_comment` and `get_server_info` bypass the breaker. `submit_ticket` and `publish_server_info` respect it.

## Ticket Filing and Dedupe

`fileTicket(ticket, deduplicationKey?, opts?)` is fire-and-forget. The key defaults to the title. A call is suppressed (logged, not sent) when the key is permanently suppressed, is currently in flight, or was filed less than the cooldown ago (`opts.cooldownMs`, else `TICKET_COOLDOWN_MS`). On a non-null result the key is recorded; on `null` or error the in-flight mark is cleared so a later call can retry.

State is in process memory (`recentlyFiled`, `permanentlySuppressed`, `inflight`), so it is per pod and resets on restart. `clearSuppression(prefix)` removes matching keys.

### Auto-Filers

| Function | Dedupe key | Notes |
| --- | --- | --- |
| `filePortalApiTicket` | `portal-api:<method>:<path>:<status>` | Severity high for status 500 and above or 0, else medium; `to_portal_id` 1. No call site found in the repository code outside its own definition |
| `fileEventDeadLetterTicket` | `event-dead-letter:<eventId>` | Skipped if a cached open outbound ticket title has a first bracketed segment equal to the event id (or contains `: <eventId>` when there is no bracket); 24 h cooldown. The ticket title is `Event dead-lettered [<eventId>]: ...`. Called from `server/services/eventRetryWorker.ts` |
| `fileWebhookDeadLetterTicket` | `webhook-dead-letter:<subscriptionId>::<errorClass>` | One ticket per subscription and error class; later failures are added as comments via `add_ticket_comment`. Error classes come from `classifyWebhookError` (for example `timeout`, `dns_failure`, `http_503`). Open-ticket index cached 5 min. Called from `server/services/webhookDispatcher.ts` |
| `fileSevisTicket` | `sevis:<errorType>` for `certificate_error` and `configuration_missing` (permanent suppression), else `sevis:<errorType>:<errorCode>:<batchId>` | Severity high for `submission_failure` and `certificate_error`. Called from `server/services/sevisOperationsService.ts` |
| `fileTicket` direct | caller-defined | `server/services/cbcPortalUploadIngestion.ts` files a ticket when package handoff fails and the incident was not deduplicated |

The open-ticket title lookup used by the event dead-letter filer is cached for 5 minutes.

## Startup Routine

`server/index.ts` calls `startupTicketCheck()` after boot. It runs `ensureServerInfoRegistered` (publishes server info if none is registered), `bulkCloseNoiseTickets` (only if `MCP_BULK_CLEANUP` is `true`; closes with `resolved_by_portal_id` 2 and a cleanup note), then `checkInboundTickets` (lists open inbound tickets and, unless `MCP_AUTO_ACKNOWLEDGE` is `false`, sets each to `acknowledged`). Failures are logged and swallowed.

## Admin Routes

Both routes use `isAuthenticated` and `requirePermission("admin.access")` in the handler. The mutation registry (`server/middleware/mutationRegistry.ts`) lists the PATCH route with permission `dashboard.view`; the handler is what enforces access, so `admin.access` is the permission the route code requires. Success bodies use the envelope `{ success: true, data }`.

### GET /api/admin/tickets

| Query | Values |
| --- | --- |
| `direction` | `inbound`, `outbound`, `all` (default: both fetched) |
| `status` | `open`, `acknowledged`, `in_progress`, `resolved`, `closed`, `all` |
| `type` | `issue`, `feature_request`, `all` |

Response 200: `data` is `{ inbound: [...], outbound: [...] }`. When the MCP call returns `null` (no key), the response is still 200 with empty arrays and `message: "MCP not configured"`. Query values are cast, not validated. With no `direction`, each side's errors are swallowed and become `null`; with a single direction an MCP error propagates to the global error handler.

### PATCH /api/admin/tickets/:ticketId

Audited with action `ticket.update`, entity type `ticket`. Body fields: `status`, `body`, `resolution_notes` (strings, extra keys allowed).

| Case | HTTP | Body |
| --- | --- | --- |
| Success | 200 | `{ success: true, data: { success: true, result } }` |
| `status` not in `open`, `acknowledged`, `in_progress`, `resolved`, `closed` | 400 | `{ error: "Invalid status. Must be one of: ..." }` (flat string) |
| None of `status`, `body`, `resolution_notes` given | 400 | `{ error: "At least one of 'status', 'body', or 'resolution_notes' is required" }` (flat string) |
| `update_ticket` returned `null` (no key) | 503 | `{ error: "MCP not configured" }` (flat string) |
| A body field is not a string | 400 | Standard envelope, top-level `error.code` `VALIDATION_ERROR` |

When `status` is `resolved`, the handler also sends `resolved_by_portal_id` 2. Setting `status` to `closed` does not set it. `update_ticket` bypasses the circuit breaker, so an open breaker does not itself produce the 503.

## Data Model

The Hub has no tables for this feature in the files read. Ticket state lives on the MCP server. The client's `Ticket` shape: `id`, `from_portal_id`, `to_portal_id`, `title`, `body`, `severity` (`low`, `medium`, `high`, `critical`), `status` (`open`, `acknowledged`, `in_progress`, `resolved`, `closed`), optional `type` (`issue`, `feature_request`), `category`, `tags`, `warp_run_id`, `resolution_notes`, `resolved_by_portal_id`, `created_at`, `updated_at`. Dedupe state is in-process memory only.

## Known Gaps

- Whether the integration is live in any environment is not confirmed: manifests say it was decommissioned on 2026-08-26, yet the code and secrets remain.
- The HTTP status for an MCP error thrown from `PATCH` (HTTP error, timeout) was not traced through the error handler; it propagates from `asyncHandler`.
- `scripts/lib/mcp-base-url.cjs` (the CJS copy of the resolver) was not read.
