# MCP server

SkyPortal exposes a [Model Context Protocol](https://modelcontextprotocol.io)
endpoint at `/mcp` so AI assistants can read and write data through the same
API and permission checks as any other client.

The endpoint implements protocol revision
[2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28) over the
Streamable HTTP transport, and only that revision: there is no `initialize`
handshake, no session, and no server-initiated stream. Every request is a
self-contained JSON-RPC POST that carries its protocol version and client
capabilities in `_meta`, so requests can be served by any app process behind
the load balancer. A client that knows this revision but opens with an `initialize`
handshake anyway gets `MethodNotFound`: its version is fine, and only the
handshake is not.

## The pre-2026 handshake (deprecated)

Assistants that do not speak 2026-07-28 yet -- Codex, Cursor and others -- open
with `initialize` and then send bare JSON-RPC, with no `Mcp-Method` header and
no `_meta`. That profile is answered too, so those clients reach the same tools.

It is deprecated, and is here only until those clients catch up. Two things keep
it from costing anything structurally:

- **No session is issued.** The old transport makes `Mcp-Session-Id` optional,
  and declining to send one leaves every request independent of the process that
  served the handshake -- the same property the modern profile has by design.
- **Nothing new is built on it.** `server/discover` does not advertise it, the
  strict header and `_meta` checks still apply to every 2026-07-28 request, and
  each handshake is logged with the client that sent it, so it is possible to
  tell when the path is safe to delete.

## Authentication

Send a SkyPortal API token in the `Authorization` header, either as
`Bearer <token>` (MCP convention) or `token <token>` (SkyPortal convention).
Tools run with that token's permissions.

## Methods

| Method            | Purpose                                                    |
| ----------------- | ---------------------------------------------------------- |
| `server/discover` | Supported protocol versions, capabilities, server identity |
| `tools/list`      | Tool definitions with input schemas                        |
| `tools/call`      | Invoke a tool                                              |

## GCN events

Five tools cover multi-messenger events, so an assistant can answer questions
about a trigger and reply in the discussion on it.

| Tool                        | Purpose                                                                      |
| --------------------------- | ---------------------------------------------------------------------------- |
| `get_gcn_events`            | List or search events; `partialdateobs` matches a dateobs prefix or an alias |
| `get_gcn_event`             | One event in full, including its GCN circulars                               |
| `get_gcn_event_extractions` | Structured data a pipeline extracted from the circulars                      |
| `get_gcn_event_comments`    | The discussion on the event                                                  |
| `post_gcn_event_comment`    | Reply in that discussion                                                     |

`get_gcn_event_extractions` reads the `gcneventextractions` table, where any
producer may store machine-readable values parsed out of an event's prose.
`origin` names the producer and the payload is that producer's own shape, so
filter by `origin` when you care which pipeline it came from.

## Broker filters

Ten tools cover a filter from nothing to running on the live alert stream, so
an assistant can build one from a description of what the user wants to catch.

| Tool                              | Purpose                                                              |
| --------------------------------- | -------------------------------------------------------------------- |
| `get_filter_targets`              | The groups, streams and filter-capable brokers the token can use     |
| `get_counterpart_search_defaults` | ZTF quality cuts and window to start from, as an editable pipeline   |
| `get_alert_schema`                | Fields a pipeline may reference, as dotted paths with their types    |
| `post_filter`                     | Create the filter on a group and stream                              |
| `run_broker_filter`               | Preview: a count, or the alerts themselves when `sort_by` is given   |
| `post_broker_filter_version`      | Add a compiled pipeline as a new version                             |
| `validate_broker_filter_version`  | Ask the broker whether a version is fit to run                       |
| `activate_broker_filter_version`  | Make a version the one the broker runs                               |
| `get_broker_filter`               | Read a filter: its versions, which is active, its auto-save settings |
| `diff_broker_filter_versions`     | Unified diff of two versions' pipelines                              |

Three things about this sequence are not visible from the schemas, so they are
also stated in the server's `instructions`:

- **The stream bounds what the filter can see.** A cut on data the stream does
  not carry passes nothing, and looks identical to a cut nothing satisfies.
- **`get_alert_schema` is not optional.** A pipeline that references a path the
  survey does not have matches no alerts and reports no error, which is the
  same outcome as a filter that is merely too tight. The tool flattens the
  broker's Avro schema to dotted paths, marking nullable fields with `?` and
  arrays with `[]`, and takes `search` or `prefix` because the full ZTF schema
  is about a thousand paths.
- **Activation is gated on validation.** Posting a version validates it and
  `post_broker_filter_version` returns that verdict; activating a version with
  no passing verdict is refused.
- **There is a starting point, so nobody writes cuts from nothing.**
  `get_counterpart_search_defaults` returns the cuts and window the GCN
  crossmatch service runs with, deep-copied so editing the result does not
  touch what the service uses. It suggests `sort_order: Ascending`, which
  keeps the alerts nearest the trigger, where the service's own default keeps
  the newest.

`get_filter_targets` passes on only the broker fields a filter needs.
`GET /api/brokers` returns each broker's `altdata`, which holds its Kafka
credentials, and none of that reaches the model.

## Broker data

Four tools read the alert stream itself, rather than the filters that run on
it: what a survey saw at a position, what an object did over time, and what
catalogs say is already there.

| Tool                          | Purpose                                                         |
| ----------------------------- | --------------------------------------------------------------- |
| `list_brokers`                | Broker ids and the operations each one supports                 |
| `get_broker_alerts`           | Alerts by object, by position and time window, or one by candid |
| `get_broker_alert_photometry` | An object's alert photometry as a light curve                   |
| `crossmatch_broker_catalogs`  | A position against the broker's reference catalogs              |

- **`broker_id` is not guessable, and capabilities differ.** `list_brokers`
  reports both, and strips `altdata` for the reason given above. A broker
  answers only the operations it lists.
- **Alerts come back trimmed.** A ZTF packet carries three cutouts and the
  object's whole previous-detection history; `get_broker_alerts` returns the
  photometry and vetting fields with the history reduced to a count, and
  `full=true` for the whole packet when that is really wanted.
- **Bound a positional search in time.** Without `jd_start` and `jd_end` a cone
  search returns every alert ever recorded at that position, which for a
  well-observed field is large and rarely what was meant. `convert_time` turns
  a date into the JD these take.
- **`crossmatch_broker_catalogs` searches catalogs, not alerts.** It answers
  whether a candidate sits on a known star or galaxy, which is a different
  question from what the survey detected there.
- **A GCN region is not a cone.** `get_broker_alerts` searches a cone in time;
  an event's credible region reaches the broker only through
  `run_broker_filter` with `dateobs`, which the preview endpoint turns into a
  MOC. Searching an old GRB means that tool, with
  `get_counterpart_search_defaults` for the pipeline to start from.

## What the assistant may call

The assistant service runs the tool loop itself, and does not offer the model
every tool the endpoint exposes. Read-only tools are always offered. A tool that
writes is offered only on the page whose subject it writes to: the five filter
write tools in `FILTER_WRITE_TOOLS` are offered when the user asks from a
filter page, and nowhere else. `call_tool` then refuses anything that was not
offered, so the selection is enforced twice.

The reason is that most of what the assistant reads was written by someone else
-- GCN circulars, comments, annotations -- and a model can be talked into a
write by the text of what it read. Scoping the write tools to the page keeps
the reachable damage to the one filter the user is already editing, and every
call still runs under that user's own token with the usual permission checks.
Activating a version remains gated on a passing validation.

Each answer records what it ran. `AssistantMessage.tool_calls` holds the calls
in order as `[{name, arguments, ok, summary}]`, and `AssistantMessage.proposal`
holds a filter pipeline the assistant arrived at, read back out of that trace.
A pipeline it never previewed is not offered: the preview is the evidence that
it matches anything, and offering one without it invites saving a filter that
passes nothing. The filter page renders both under the answer.
