Metadata-Version: 2.4
Name: ct-jsm-ops-mcp-server
Version: 0.1.0
Summary: CloudThinker MCP server for Jira Service Management Operations: reads JSM_* env at startup, auto-connects to the JSM ops REST API, and serves alert, on-call, and idempotent alert-lifecycle tools over stdio.
Project-URL: Homepage, https://github.com/cloudthinker/ct-jsm-ops-mcp-server
Author: CloudThinker
License: Apache-2.0
License-File: LICENSE
Keywords: cloudthinker,incidents,jira,jira-service-management,jsm,llm,mcp,on-call,opsgenie
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Requires-Dist: fastmcp<3,>=2.0
Requires-Dist: httpx>=0.27
Description-Content-Type: text/markdown

# ct-jsm-ops-mcp-server

CloudThinker MCP server for **Jira Service Management Operations** — alerts,
on-call schedules, and an idempotent alert lifecycle, served over stdio.

Reads `JSM_*` env at startup, resolves the site's `cloudId`, proves the
credential can actually read Operations, then serves tools. There is no
connect or login tool: the first tool call works.

## Why this exists

Atlassian's own Rovo MCP server exposes only four JSM ops tools
(`getJsmOpsAlerts`, `getJsmOpsScheduleInfo`, `getJsmOpsTeamInfo`,
`updateJsmOpsAlert`), and those are reachable **only under API-token auth, not
OAuth** — which is the auth CloudThinker's `atlassian` connection uses. That
surface also has no alert timeline, no responder management, and no assign or
escalate. So this is a from-scratch wrapper over the JSM ops REST API
(`https://api.atlassian.com/jsm/ops/api/{cloudId}/v1`) plus the Jira platform
API for the incident issue itself.

## Environment

| Env var | Required | Default | Purpose |
|---|---|---|---|
| `JSM_SITE_URL` | yes | — | Site origin, e.g. `https://acme.atlassian.net`. Project and board URLs are accepted and normalized back to the origin. |
| `JSM_SITE_EMAIL` | yes | — | The Atlassian account the API token belongs to. |
| `JSM_API_TOKEN` | yes | — | Atlassian API token from id.atlassian.com. Sent as `Authorization: Basic base64(email:token)`. |
| `LOG_LEVEL` | no | `info` | Also mirrored into `FASTMCP_LOG_LEVEL`. |

`cloudId` is **not** an input. It is resolved from `JSM_SITE_URL` via
`/_edge/tenant_info`; it is a UUID the user has no reason to know.

## Startup preflight

The server exits non-zero rather than starting when Operations is not usable,
so the CloudThinker connection test reports the real reason instead of letting
the failure surface mid-incident:

| Condition | Exit | Reported as |
|---|---|---|
| Missing env var | 2 | which var is unset |
| Unparseable `JSM_SITE_URL` | 2 | expected form |
| Site or `cloudId` unreachable | 1 | check the site URL |
| 401 on the ops probe | 1 | API token, not a password, not a Rovo token |
| 403 on the ops probe | 1 | Operations not usable: plan lacks it, or the account cannot open it |
| 404 on the ops probe | 1 | that id does not exist on this site |

A JSM site without Operations authenticates perfectly well and then answers
every ops path with `403 {"code": 40301, "message": "Account does not have
access to Opsgenie."}` — verified against a live site. Auth success alone proves
nothing, so the probe makes one real ops read (`/alerts` and `/schedules`, one
record each).

The 403 does not distinguish "the plan has no Operations" from "this user
cannot open Operations", so neither does the error. It names both and quotes
the provider's own message.

## Tools

Read-only (8): `list_alerts`, `get_alert`, `get_alert_timeline`,
`list_schedules`, `get_on_call`, `get_team`, `get_jsm_incident`,
`get_action_status`.

Idempotent writes (4): `acknowledge_alert`, `assign_alert`,
`add_alert_responder`, `add_alert_note`.

Destructive writes (2): `close_alert`, `escalate_alert`.

Tool `annotations` are load-bearing. CloudThinker reads `readOnlyHint` and
`destructiveHint` off the MCP definition to route human approval, so
`close_alert` and `escalate_alert` — the two actions CloudThinker cannot undo —
always reach the approval gate.

## The write contract

Every write runs the same three steps, so retrying is safe by construction:

```
read alert  ->  decide  ->  post  ->  poll requestId to settled
```

Alert actions are processed asynchronously: the POST returns only a
`requestId`, so "the action worked" is knowable only by polling
`/alerts/requests/{id}`. Each write returns one of:

| status | meaning |
|---|---|
| `applied` | the action ran and the provider confirmed it |
| `noop_already_applied` | the alert was already in the target state; nothing was sent |
| `conflict` | the alert moved somewhere the action does not apply from; nothing was sent, a recovery path is returned |
| `pending` | accepted but still queued; poll `get_action_status`, do not re-send |
| `failed` | the provider rejected it, or the alert was not found |

Idempotency is read from the provider, not from local bookkeeping, so it stays
correct when the same action arrives from two paths or after a restart. The
server never forces: a closed alert is never reopened.

`escalate_alert` is the exception with no provider-side idempotency handle —
no alert field says "already escalated to X" — so only the "still open" check
applies, and a replay pages someone twice. That is why it is destructive.

## Local checks

```bash
uv sync
uv run pytest -q                              # pure helpers + lifecycle, offline
uv run python tests/smoke_listtools.py        # tool names + annotations, no network
```

## Live-site verification required before publishing

These are doc-derived and must be confirmed against a real JSM site with
Operations. Fixtures built from documentation prove code against the wrong
schema.

1. **Response envelopes.** `{"values": [...], "links": {...}}` is assumed for
   `/alerts` and `/schedules`. `as_values` also accepts a bare array and
   `data`/named alternates — record which one is real.
2. **Alert logs and notes shapes.** The note body key is assumed to be `note`
   with `text` as a fallback. `add_alert_note` dedupe correctness depends on
   this exact key.
3. **`/teams/{id}`.** The team endpoint's path and response shape are the least
   documented of the set; confirm before trusting `get_team`.
4. **Action request bodies.** `{"owner": {...}}` for assign,
   `{"responder": {"id","type"}}` for responders, `{"escalation": {"id"}}` for
   escalate, `{"note": ...}` for acknowledge/close/notes.
5. **Request-status envelope.** `isSuccess` is assumed, flat or under `data`.
   If a queued request instead 404s for longer than the poll budget, tune
   `ACTION_POLL_ATTEMPTS`.
6. **`listTools` shows exactly the 14 tools** and nothing else.

## License

Apache-2.0.
