# Agent-native (MCP)

> Run SchedStack's Model Context Protocol server so an agent can preview, create, and manage durable schedules with forgiving timing inputs and self-correcting errors.

SchedStack ships a first-class [Model Context Protocol](https://modelcontextprotocol.io)
server so an agent can schedule durable HTTP deliveries directly — no glue code, no
hand-written API client. It speaks MCP over **stdio**, exposes **ten** scheduling tools,
and is scoped to exactly one `(project, mode)` by the API key you give it.

Two things make it agent-native rather than a thin API wrapper:

- **A forgiving `when` parser** — agents pass natural timing strings (`"24h"`, `"in 2h"`,
  `"2026-07-01 09:00"`, `"0 9 * * *"`) and the server resolves them, including DST-correct
  wall-clock and recurring schedules.
- **Self-correcting errors** — a bad input comes back as a plain-language hint the agent
  can act on (`"that time is in the past — give a future time, or a duration like \"24h\""`),
  not an opaque protocol failure.

## Run the server

Run the `sched mcp` subcommand. The MCP server needs **direct database access**, not just
an API key — it connects to SchedStack's database rather than calling
`api.schedstack.com` — so it requires both your API key and a database URL, and serves
over stdio:

```bash
SCHED_API_KEY=sk_live_… SCHED_DATABASE_URL=postgres://… sched mcp
```

Both env vars are required in **every** deployment — the command exits with
`SCHED_DATABASE_URL is required` (or `SCHED_API_KEY is required`) if either is missing.
Use an `sk_test_…` key to drive the [test mode](/docs/concepts/modes/) sandbox, or an
`sk_live_…` key for production. Every tool call is bound to that key's project and mode —
the agent can only see and act on schedules in that scope.

**You run this next to the database**

Because the MCP server needs direct database access, you run it wherever you can reach
that database. There is no key-only hosted MCP mode today: a deployment that only has an
API key (and no database access) cannot start the server.

## Connect an MCP client

Most clients launch the server as a child process and pass env through. Register `sched`
as a stdio server with `args: ["mcp"]` and **both** env vars set:

In `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "schedstack": {
      "command": "sched",
      "args": ["mcp"],
      "env": {
        "SCHED_API_KEY": "sk_live_…",
        "SCHED_DATABASE_URL": "postgres://…"
      }
    }
  }
}
```

```json
{
  "mcpServers": {
    "schedstack": {
      "command": "sched",
      "args": ["mcp"],
      "env": {
        "SCHED_API_KEY": "sk_live_…",
        "SCHED_DATABASE_URL": "postgres://…"
      }
    }
  }
}
```

The server announces itself as `sched` / `SchedStack`. Once connected, the ten tools below
are available to the agent.

**Treat these as secrets**

The config embeds a live API key **and database credentials** in plaintext. Scope the key
to `test` mode for development, keep the file out of version control, and rotate the key
(and database password) if either leaks.

## The forgiving `when` parser

Every timing input — `preview_schedule`'s and `create_schedule`'s `when` — runs through one
parser. It accepts four forms and picks the kind (`one_shot` vs `recurring`) for you:

| Form | Examples | Resolves to |
|---|---|---|
| **Relative duration** | `"24h"`, `"in 2h"`, `"90s"`, `"1h30m"` | one-shot, now + duration |
| **RFC3339 instant** | `"2026-07-01T09:00:00Z"`, `"2026-07-01T05:00:00-04:00"` | one-shot, that exact instant |
| **Offset-less wall-clock** | `"2026-07-01 09:00"`, `"2026-07-01T09:00:00"` | one-shot, that local time **in `timezone`** (DST-correct) |
| **5-field cron** | `"0 9 * * *"`, `"*/15 * * * *"` | recurring, in `timezone` (default `UTC`) |

How it decides: **five or more space-separated fields** is treated as cron; an `"in "`
prefix is stripped and the rest parsed as a [Go duration](https://pkg.go.dev/time#ParseDuration);
otherwise it tries the timestamp layouts. An **offset-less** timestamp is interpreted in the
`timezone` you pass (not UTC), so `"2026-07-01 09:00"` with `timezone: "America/New_York"`
means 9am New York — mapped across daylight-saving transitions the same way recurring
schedules fire. An RFC3339 string carries its own offset and is taken as-is.

What it deliberately does **not** accept:

- **ISO8601 durations** like `"PT2H"` — use `"2h"` instead.
- **Seconds-precision cron** — only standard 5-field cron (minute hour day-of-month month
  day-of-week).
- **Sub-second delays** — the minimum is `~1s` (`"500ms"` is rejected). SchedStack is a
  durable scheduler, not a sub-second timer.
- **Times in the past** — both relative and absolute inputs must be in the future.

Bad inputs return guidance, e.g. `"couldn't parse \"PT2H\" — try a duration (\"24h\", \"in 2h\"), an ISO8601 time, or a cron (\"0 9 * * *\")"`.

**Preview before you commit**

`preview_schedule` resolves a `when` and returns the next fire times **without creating
anything** — the cheap, side-effect-free way for an agent to confirm it parsed the user's
intent. `preview_schedule` and `create_schedule` both return the **next 5 fire times**
(`next_runs`) so the agent can show the user exactly when deliveries will land.

## The ten tools

The agent sees exactly these tools — no more. There is intentionally **no** `update`,
`reschedule`, or endpoint-management tool over MCP (to change timing, cancel and create
again; [endpoint profiles](/docs/guides/endpoint-profiles/) are managed via the HTTP API).

### Scheduling

#### `preview_schedule`

Resolve a timing input and return the next fire times without creating a schedule.

| Arg | Required | Notes |
|---|---|---|
| `when` | yes | duration, RFC3339, offset-less wall-clock, or 5-field cron |
| `timezone` | no | IANA name (e.g. `America/New_York`); applies to cron and offset-less times. Default `UTC` |

Returns `kind` (`one_shot` or `recurring`) and `next_runs` (up to 5 RFC3339 instants).

#### `create_schedule`

Create a durable schedule. Delivery is at-least-once and retried until it lands — see
[Retries & dead-letter](/docs/concepts/retries-and-dead-letter/).

| Arg | Required | Notes |
|---|---|---|
| `endpoint` | yes | the `https` URL to deliver to |
| `when` | yes | same forms as `preview_schedule` |
| `method` | no | HTTP method (default `POST`) |
| `body` | no | request body; sent as `application/json` |
| `timezone` | no | IANA name for cron / wall-clock times (default `UTC`) |
| `ttl` | no | deliver-by deadline as a duration, e.g. `"1h"` |
| `metadata` | no | string→string labels |

New schedules use the default retry policy (8 attempts, exponential backoff from `5s` to
`1h`, jittered). The response is a schedule summary including `id`, `state`, `kind`,
`next_fire_at`, and the resolved `next_runs`.

### Lifecycle

#### `get_schedule`

Retrieve a schedule by id. Arg: `id` (required).

#### `list_schedules`

List schedules in this scope, cursor-paginated.

| Arg | Required | Notes |
|---|---|---|
| `state` | no | `active` \| `paused` \| `canceled` |
| `kind` | no | `one_shot` \| `recurring` |
| `cursor` | no | opaque pagination cursor from a prior call |
| `limit` | no | page size (default 20, max 100). An out-of-range value (`<= 0` or `> 100`) falls back to 20 — it is **not** clamped to 100 |

Returns `schedules`, plus `next_cursor` and `has_more` for paging.

A schedule is only ever `active`, `paused`, or `canceled`. The tool's own JSON schema
still advertises a fourth value, `completed`, but no schedule ever reaches that state, so
filtering on it returns nothing. Treat `completed` as a stale label, not a lifecycle state.

#### `pause_schedule`

Pause a schedule (reversible — future occurrences stop until resumed). Arg: `id` (required).

#### `resume_schedule`

Resume a paused schedule. Arg: `id` (required).

#### `cancel_schedule`

Cancel a schedule (terminal — stops all future occurrences). Arg: `id` (required).

### Deliveries

A delivery is one occurrence of a schedule. See
[Dead-letter & replay](/docs/guides/dead-letter-and-replay/) for the lifecycle.

#### `list_deliveries`

List deliveries (occurrences), cursor-paginated.

| Arg | Required | Notes |
|---|---|---|
| `status` | no | e.g. `scheduled` \| `succeeded` \| `dead_letter` \| `expired` |
| `schedule_id` | no | restrict to one schedule |
| `cursor` | no | opaque pagination cursor |
| `limit` | no | page size |

Returns `deliveries`, plus `next_cursor` and `has_more`.

#### `get_delivery`

Retrieve a delivery by id. Arg: `id` (required). The summary includes `status`,
`scheduled_for`, `attempt_count`, and `replay_of` (set when this delivery is a replay).

#### `replay_delivery`

Replay a terminal delivery (dead-lettered, expired, or succeeded) as a **new** occurrence.
Arg: `id` (required). The new delivery records `replay_of` pointing at the original.

## What the agent should still know

The MCP server is a control surface, not a replacement for the delivery contract. Tell your
agent — or your receiver — that:

- **Delivery is at-least-once.** A schedule can fire more than once. Your endpoint must
  verify the `Sched-Signature` and dedupe on the `Idempotency-Key` header. See
  [Verify webhook signatures](/docs/guides/verify-signatures/) and
  [Idempotency](/docs/concepts/idempotency/).
- **The SLO covers dispatch initiation, not delivery completion** — see the timing
  guarantee in [Quickstart](/docs/quickstart/).
- **There is no edit-in-place.** Changing a schedule's timing is cancel-then-create; the
  HTTP API (not MCP) owns endpoint profiles and richer retry policies.

## Related

- [Quickstart](/docs/quickstart/) — the same create flow over plain HTTP.
- [Recurring schedules & DST](/docs/guides/recurring/) — how cron and timezones resolve.
- [Test & live modes](/docs/concepts/modes/) — what your key's mode scopes the agent to.
- [Dead-letter & replay](/docs/guides/dead-letter-and-replay/) — what `replay_delivery` acts on.
