# Maintenance Due: Streamable HTTP MCP Due engine over stateless Streamable HTTP. Due is the log plus the clock. Free, and stays free. Org is the tenant (solo is a one-member org); the membership bearer and the log leave with you. Live MCP endpoint on this Worker. This endpoint is stateless. Do not send a sticky `Mcp-Session-Id`. DELETE /mcp returns 405 (there is no session to terminate). GET with Accept: text/event-stream returns 405 (no standalone SSE stream). POST JSON-RPC 2.0 to /mcp. Same membership-scoped Bearer as REST. Wrong or revoked bearer → HTTP 401 (does not mint an org). ## Handshake (no session) `initialize` → `notifications/initialized` → `tools/list` / `tools/call` / `ping`. Each POST is independent. You do not need a prior initialize to call tools. Supported `MCP-Protocol-Version`: 2025-03-26, 2025-06-18, 2026-07-28. Responses are JSON envelopes: `summary`, `items[]`, `coming[]`, `upcoming[]`, `snoozed[]`, `hints[]`, `next`. Writes include a due/inbox slice — do not re-fetch. `items[].overdue` is how late when due. `items[].last` / `upcoming[].last` is the last Event (shop, cost, parts, meters). `upcoming[]` has remaining miles/hours, `next_due_at`, `next_due_meters` (the work-order odometer), `pace` (origin→latest per day), and `eta` (leftover / pace). `snoozed[]` is hidden until `snoozed_until` (vacation week; due math unchanged). `get_due as_of` / `within_days` / `within_meters` fill `coming[]` so a Friday board is one hop (`within_days` includes `eta`). `get_due { asset }` (alias `asset_name`; case-insensitive exact) is the Sprinter board; `get_due { assets }` (or a list on `asset`) is Oak Street + Pine Court in one hop (`by_asset[]` is due_count per name); `get_due { name }` (aliases `service` / `job` / `service_name`; case-insensitive exact) is the oil board across the vans; `service_id` is one Service. `get_history { asset }` is that van's log. `get_history { assets }` (or a list on `asset`) is Oak Street + Pine Court in one hop (`by_asset[]` per name). `get_history { name }` is the oil log across the vans (same name axis; `totals.cost` is every matching Service). `get_history { meter }` is the reading log (fleet miles last week; `totals.used` is last − first per Asset, then summed). `get_history` names `totals.cost` on `since` / `until` / `shop` so last year's shop spend is one hop. `list_assets` names `assets[].meters` so a fleet odometer roll-call is one hop (`get_asset` names the same field). `list_services { asset }` / `get_asset { asset }` is the Sprinter catalog (id, name, rule, last). `get_asset { assets: ["Transit", "Sprinter", "Box Truck"] }` (or a list on `asset`) is Friday fleet prep in one hop (`by_asset[]` is the per-Asset row: meters, services[], steerage snippet; `errors[]` names an Asset that did not return). `list_services { name }` is the oil jobs across the vans (last on each van). `append_event { asset, name }` is oil on the Sprinter (do not list_services just to recover `svc_…`). `append_event { assets, name }` (or a list on `asset`) completes the same job on every named Asset in one hop (`errors[]` names an Asset that did not get it; do not loop append_event per house). `append_reading { asset }` is miles on the Sprinter (do not list_assets just to recover `ast_…`). `archive_asset { asset }` is Dump Truck (do not list_assets just to recover `ast_…`; asset_id still works; one live Asset does not need asset). `archive_asset { assets }` (or a list on `asset`) sells or retires the whole fleet in one call (Transit + Sprinter + Box Truck; `by_asset[]` is archived / archived_at per name; `errors[]` names an Asset that did not get it; do not loop `archive_asset` per van; `unarchive_asset { assets }` restores the roster). `archive_service { asset, name }` is Cabin filter on the Sprinter (do not list_services just to recover `svc_…`; oil stays). `snooze_service { asset, name, until }` is Trash night on the Sprinter (do not list_services just to recover `svc_…`; due math unchanged). `set_rule { asset, name }` is Grease on the Civic (do not list_services just to recover `svc_…`; only if they stated an interval). `create_service { asset, name }` is Brake fluid on the Civic (do not list_assets just to recover `ast_…`; asset_id still works; one live Asset does not need asset). `update_asset_preferences { asset, steerage }` is Always Eastside on the Civic (do not list_assets just to recover `ast_…`; asset_id still works; one live Asset does not need asset; do not stash a lasting shop preference in Asset notes). `format=md` on get_due / get_history keeps the previous Markdown bodies (Muse). ## Public tools (no auth) - health - bootstrap { seed?: true, name?, email? } — mint org + owner token; seed returns Truck A due + next append_event. Omit Authorization; a present bearer does not mint. - accept_invite { token } / decline_invite { token } — no Authorization. Accepting joins under RBAC. ## Authenticated tools (Authorization: Bearer ) - list_assets dashboard: meters (live dash) + due_count + next_due (overdue when currently due; remaining / next_due_at / next_due_meters / last when nothing is currently due). include_archived?: true - get_asset { asset_id? | asset? | assets? } — asset + meters (live dash) + services[] (id, name, rule, last) + steerage snippets + archived. asset (alias asset_name) is the Sprinter (case-insensitive exact); assets (or a list on asset) reads the whole fleet in one call — Transit + Sprinter + Box Truck is one ask (by_asset[] is the per-Asset row: meters, services[], steerage snippet, archived; errors[] names an Asset that did not return; do not loop get_asset per van) - list_services { asset_id? | asset? | name?, include_archived? } — catalog (id, name, rule, last). last is the last Event (shop, cost, parts, meters), including Notes-only jobs. asset is the Sprinter catalog. name is the oil jobs across the vans (last on each van). Not the due board. Omit both for the account. - create_asset { name, type?, notes? } - archive_asset { asset? | assets? | asset_id? } — asset is Dump Truck (same name axis as get_due / append_reading; do not list_assets just to recover ast_…); asset_id still works; one live Asset does not need asset; assets (or a list on asset) sells or retires every named Asset in one call (Transit + Sprinter + Box Truck; errors[] names an Asset that did not get it; do not loop archive_asset per van); sold/retired; history stays; due drops it; returns remaining due - unarchive_asset { asset? | assets? | asset_id? } — back on the due list; same name axis as archive_asset; assets (or a list on asset) puts the roster back on the due list in one call (errors[] names an Asset that did not get it; do not loop unarchive_asset per van) - create_service { asset? | asset_id? | assets?, name, notes?, rule?, reading?, readings?, last_done_at?, last_done_meters? } — asset is Brake fluid on the Civic (same name axis as get_due / append_reading; do not list_assets just to recover ast_…); asset_id still works; one live Asset does not need asset; calendar every_months | every_days | each_year_on | each_month_on | each_month_on+every_months | each_weekday | each_weekday+every_weeks; meter every | below | above; reading is the dash; last_done_meters is last oil (not an Event); last_done_at starts the calendar clock (not an Event); do not invent intervals; assets (or a list on asset) hangs the same job on every named Asset in one call (errors[] names a gap; by_asset[] is due_count per name; do not loop create_service per house); by_asset[] origin rows ({ asset, last_done_meters?, reading?, last_done_at? }) give each named Asset its own origin (row wins over the shared origin; the rows define the roster when assets is omitted; do not loop create_service per van) - archive_service { service_id? | asset? + name? | assets? + name? } — asset + name is Cabin filter on the Sprinter (same name axis as get_due / append_event; do not list_services just to recover svc_…); service_id still works; assets (or a list on asset) + name retires the same job on every named Asset in one call (errors[] names an Asset that did not get it; do not loop archive_service per boat); retired one job; history stays; due drops that Service; oil stays - unarchive_service { service_id? | asset? + name? | assets? + name? } — that Service is back on the due list; same name axis as archive_service; assets (or a list on asset) + name puts the same job back on every named Asset in one call (errors[] names an Asset that did not get it; do not loop unarchive_service per boat) - snooze_service { service_id? | asset? + name? | assets? + name?, until } — asset + name is Trash night on the Sprinter (same name axis as get_due / append_event; do not list_services just to recover svc_…); service_id still works; assets (or a list on asset) + name hides the same job on every named Asset until one clock in one call (errors[] names an Asset that did not get it; do not loop snooze_service per house); by_asset rows ({ asset, until? }) give each Asset its own clock — row until wins over the shared until and the rows define the roster when assets is omitted; hide until a stated clock; due math unchanged; do not invent an Event - unsnooze_service { service_id? | asset? + name? | assets? + name? } — clear the snooze; back on the due list; same name axis as snooze_service; assets (or a list on asset) + name clears every named Asset in one call - set_rule { service_id? | asset? + name? | assets? + name?, expression, last_done_at?, last_done_meters? } — asset + name is Grease on the Civic (same name axis as get_due / append_event; do not list_services just to recover svc_…); service_id still works; assets (or a list on asset) + name puts the same rule on every named Asset in one call (errors[] names an Asset that did not get it; do not loop set_rule per house); only if they stated an interval; annual = every_months: 12; seasonal date = each_year_on: "10-15"; day of month = each_month_on: 1; every 3 months on the 1st = each_month_on: 1 plus every_months: 3; weekday = each_weekday: "tue"; every other Saturday = each_weekday: "sat" plus every_weeks: 2; gauge line = below: 20; last_done_at is the stated last-done date; last_done_meters is last oil (empty Event log only) - append_reading { asset? | asset_id?, meter, value } or { readings: [{ asset?, meter, value }, ...] } — asset is miles on the Sprinter (same name axis as get_due; do not list_assets just to recover ast_…); asset_id still works; one live Asset does not need asset; returns due + upcoming - append_event { service_id? | asset? + name? | assets? + name?, completed_at?, parts?, notes?, shop?, cost?, reading?, readings? } — asset + name is oil on the Sprinter (same name axis as get_due; do not list_services just to recover svc_…); assets (or a list on asset) + name completes the same job on every named Asset in one call (errors[] names an Asset that did not get it; do not loop append_event per house); service_id still works; shop + cost are the receipt (vendor aliases shop); reading logs miles/hours on the same write and stores event.meters; returns due + upcoming - get_due { asset_id?, asset?, assets?, service_id?, name?, all?, expand?, format?, as_of?, within_days?, within_meters? } — asset (alias asset_name) is the Sprinter board (case-insensitive exact Asset name; same full board as asset_id); assets (or a list on asset) is Oak Street + Pine Court in one hop (by_asset[] is due_count per name; do not scrape get_due { all: true }; do not loop get_due per van); name (aliases service / job / service_name) is the oil board (case-insensitive exact); service_id is one Service; items[] due on this clock (overdue is how late; last is shop/cost/parts/meters); snoozed[] until snoozed_until; as_of moves the calendar; coming[] is within_days / within_meters (within_days includes eta); upcoming[] remaining / next_due_at / next_due_meters / pace / eta / last - get_history { asset_id? | asset? | assets? | service_id? | name? | meter?, since?, until?, shop?, before?, limit?, format? } — asset (alias asset_name) is one van by name (the Sprinter log); assets (or a list on asset) is those logs in one hop (by_asset[] per name; do not loop get_history per van); meter (alias gauge) is the reading log (fleet miles last week; totals.used is last − first per Asset, then summed); name (aliases service / job / service_name) is the oil log across the vans (case-insensitive exact); service_id is one Service (shop + cost + meters); asset_id is the mixed log; omit the id with since / until / shop for account spend; totals.cost is the filter, not the page; capped; truncated + cursor - get_preferences — full account notes (empty default) - update_preferences { steerage } — replace; null clears; not an Event - get_asset_preferences { asset? | assets? | asset_id? } — full asset notes; asset is Always Eastside on the Civic (same name axis as get_due / create_service; do not list_assets just to recover ast_…); assets (or a list on asset) reads the whole fleet's shop notes in one call (by_asset[] per name; errors[] names an Asset that did not return; do not loop get_asset_preferences per van) - update_asset_preferences { asset? | assets? | asset_id?, steerage?, by_asset? } — asset is Always Eastside on the Civic; assets (or a list on asset) writes the same shop preference on every named Asset in one call (errors[] names an Asset that did not get it; by_asset[] is the per-Asset steerage row; do not loop update_asset_preferences per van); by_asset rows ({ asset, steerage? }) give each named Asset its own note — row wins over the shared steerage, a row that omits keeps the shared default, the rows define the roster when assets is omitted (cap 20), and top-level steerage is required when a row omits its own; asset_id still works; one live Asset does not need asset; null clears; not an Event; do not stash a lasting shop preference in Asset notes - submit_feedback { kind: issue|improvement, title, body, how_found? } — issue requires how_found; ops queue; no account_id - get_org / list_members - leave_org ## Personal Agent Protocol Session Tokens Poppy v0.1 agents (discovery at `GET /.well-known/poppy.json`) call this endpoint with `Authorization: Bearer ` minted at `POST /oauth/token` with `resource=https://maintenancedue.com/mcp`. Tool schemas and annotations are the same. `poppy:read` covers tools with `readOnlyHint: true`; every other tool, `events/subscribe`, and `events/unsubscribe` need `poppy:write` (HTTP 403, `WWW-Authenticate: Bearer error="insufficient_scope", scope="poppy:write"`). A signed-out Session is 403 `sign_in_required` on member tools; public tools still work. `leave_org` is refused for Session Tokens. ## MCP Events (ChatGPT plugins) Protocol 2026-07-28. `server/discover` returns `{ resultType: "complete", supportedVersions: [...], capabilities: { tools: {}, events: {} } }`. Webhook delivery only — no poll, no push, no `gap` / `terminated` control bodies. - `events/list` — the catalog. Same definitions for every account. - `events/subscribe` — `{ name, arguments?, delivery: { mode: "webhook", url, secret }, cursor: null, ttlMs? }`. Requires the membership Bearer (no principal → -32012 Forbidden). The callback must be https and resolve to a public address (private, loopback, and ULA hosts are refused; no redirects are followed). `secret` is `whsec_` + base64 of 24–64 random bytes. Before activation the server POSTs a signed `{ "type": "verification", "challenge": "…" }` envelope and requires a 2xx echoing the challenge; failure is -32015 `CallbackEndpointError` with `data.reason` (`challenge_failed` | `timeout` | `connection_refused` | `tls_error` | `http_4xx` | `http_5xx`). Verification is cached per (principal, url) for a day. Omit `ttlMs` for a 24h grant; `ttlMs` is honored (60s floor); `ttlMs: null` grants no expiry — subscriptions persist in D1 across restarts. Re-subscribe before `refreshBefore` to refresh; the id is stable for (principal, url, name, arguments). - `events/unsubscribe` — `{ name, arguments?, delivery: { mode: "webhook", url } }`. Idempotent; returns `{}`. Events (emit-only — `cursor` is always `null`, no replay): - `service.due` — a Service became newly due after a write (oil due on the Sprinter; the meter crossed 5000; the snooze cleared). Filters: `asset` / `asset_id`, `name` / `service` (case-insensitive exact). Payload: `asset_id`, `asset`, `service_id`, `service`, `reason`, `overdue`, `next_due_meters`. - `event.appended` — a completion landed in the log (oil changed on the Sprinter, $42 at Joe's Independent). Same filters. Payload: `event_id`, `asset_id`, `asset`, `service_id`, `service`, `completed_at`, `shop`, `cost`, `meters`. - `service.completed` — the same completion under a completion-first name (identical schema and emit; subscribe to either). - `asset.created` — a van, house, or boat joined the account. No filters. Payload: `asset_id`, `asset`, `type`, `created_at`. - `asset.archived` — sold or retired; it left the due list (history stays). Filters: `asset` / `asset_id`. Payload: `asset_id`, `asset`, `archived_at`. Deliveries POST one `{ eventId, name, timestamp, data, cursor: null }` occurrence signed per Standard Webhooks (`webhook-id` = `eventId`, `webhook-timestamp`, `webhook-signature`, plus `X-MCP-Subscription-Id`). Verify the signature before parsing; dedupe on `eventId`. Bodies stay under 256 KiB. Filters apply server-side before delivery. ## Resources (text/plain) - maintenance-due://skill - maintenance-due://agent-prompt - maintenance-due://llms REST companions remain: JSON writes on /api/v1, Markdown reads on /api/v1/agent/*.