Skip to main content
Glama
README.md
# Steward

Steward sits between an AI coding agent (Cursor, in this demo) and monday.com. The agent can read one allowlisted board and propose a change. It cannot write to monday until two things are true: the board’s column contract is frozen, and a person approves the write in a browser.

The monday API token never leaves the Worker. Cursor gets a Steward-issued OAuth token that is only valid at Steward. MCP (Model Context Protocol) is how Cursor calls those tools over HTTP.

Live MCP: [https://steward.c-rogs.workers.dev/mcp](https://steward.c-rogs.workers.dev/mcp)
Approvals: [https://steward.c-rogs.workers.dev/approvals](https://steward.c-rogs.workers.dev/approvals)

This is a hiring demo on **cameron.monday.com** (a personal sandbox). It does not talk to monday.monday.com. Default `MONDAY_API_TOKEN` in a work laptop’s shell is usually the company tenant; Steward refuses that.

## Why it is useful

Companies are wiring agents into SaaS APIs. monday’s official MCP (`https://mcp.monday.com/mcp`) exposes 60+ tools, including `all_monday_api`, which is raw GraphQL. The agent or the host holds a monday credential. Tool annotations such as `readOnlyHint` are suggestions to the model. They do not grant or deny anything. There is no tenant allowlist, no frozen schema, and no human gate. A prompt that says “just this once” can mutate whatever that token can reach.

Steward is eight tools with real checks in the handlers. Reads are scoped. Writes go through propose, approve, then commit. Status updates send monday’s stable label id (`index`). Colour names and UI sort order are rejected. monday has no `If-Match`; Steward snapshots the item at propose time and refuses the commit if it drifted.

If you are hiring for agent-plus-platform work, this is a control plane: scopes, a frozen board contract, and a human gate.

## How a write works

1. Cursor connects to `/mcp` with no monday header. Unauthenticated calls get `401` plus OAuth metadata (RFC 9728). monday JWTs get a named error, `not_a_steward_token`.
2. Cursor registers as an OAuth client (dynamic registration) and runs PKCE. You log in with a demo password and grant `steward.read`, `steward.propose`, and `steward.commit`. Steward mints an opaque token stored in KV. The token’s `aud` is `https://steward.c-rogs.workers.dev/mcp` (RFC 8707). A stolen Steward token cannot be replayed at `api.monday.com`.
3. `freeze_contract` inspects the Acme board and stores a versioned snapshot of columns in D1. People columns take user ids, not display names. Subitems live on a different board; a parent-board id for a subitem fails closed.
4. `propose_write` validates the patch against that contract, dry-runs it, and stores `{patch, expected_cas, status: pending}`. It does not call monday mutations. The tool result is `pending_approval` plus the `/approvals` URL.
5. You open `/approvals`, log in, and click Approve (or Approve & apply). CSRF is checked. Approve does not depend on Cursor still being connected.
6. `commit_proposal` requires `steward.commit`, an approved row, a matching contract, a matching CAS token, and an `Idempotency-Key`. Then the Worker calls `change_multiple_column_values`. monday’s `create_item` has no idempotency; Steward keys `(client_id, key)` in D1. Same key and same payload return the original result. Same key and a different payload return `idempotency_conflict`.
7. Every tool call appends a hash-chained audit row (hashed arguments, not raw args). If you hold the tip hash, a middle-row edit is visible. The chain does not prove the Worker never appended a lie, and a D1 admin can recompute it.

Cursor today speaks 2025-era streamable HTTP. That path cannot receive server-to-client elicitation on a stateless Worker, so `/approvals` is the gate that always works. Clients on the 2026 protocol that declare elicitation still get `inputRequired`; confirming there still does not mutate monday.

Toggle off Cursor’s `monday-mcp` and `monday-all` servers when you try this, or the agent will write around Steward.

Recorded on production (2026-08-31): freeze board `5103212880` → propose Cutover runbook `3194824173` to Done → `pending_approval` (monday still not Done) → approve on `/approvals` → `commit_proposal` with an Idempotency-Key → Status `{index: 1}` → replay of the same key returned the original result → D1 `audit` shows `propose_write`/`pending_approval` then `commit_proposal`/`committed`.

```bash
npx wrangler d1 execute steward --remote --command "select tool, decision, substr(this_hash,1,12) as tip from audit order by id desc limit 8"
```

## What it is built on

One Cloudflare Worker (`steward`, `https://steward.c-rogs.workers.dev`). Same process is the OAuth authorization server and the MCP resource server. That is a demo shortcut. A production split would keep Steward as the resource server and let Access, GitHub, or another IdP issue JWTs. Keycloak is not in this project.

| Piece | Job |
|---|---|
| `@modelcontextprotocol/server` v2 `createMcpHandler` | Streamable HTTP MCP at `/mcp`. Per-request server factory. Cloudflare has feature-frozen `McpAgent`; approval state belongs in D1, not in a session that dies when the isolate hibernates. |
| `@cloudflare/workers-oauth-provider` | OAuth 2.1, PKCE, dynamic client registration, protected-resource metadata. |
| Workers KV (`OAUTH_KV`) | Issued grants. |
| D1 (`steward`) | Contracts, proposals, idempotency keys, hash-chained audit. Interface is `StewardRepo`; `src/store/ords.ts` throws `not_configured` so an Oracle ORDS backend can slot in later. |
| monday GraphQL, `API-Version: 2025-10` | Reads and the single mutation path. Account slug must be `cameron`. |
| HTML `/authorize` and `/approvals` | Mock login (password in `DEMO_PASSWORD`). No GitHub App required to run the demo. |

Eight tools: `whoami`, `list_acme`, `get_item`, `freeze_contract`, `diff_contract`, `propose_write`, `list_proposals`, `commit_proposal`. `tools/list` is filtered by scope. Every handler checks scope again. Advertised annotations (`readOnlyHint` and friends) do not grant or deny anything.

v1 has no Durable Object and no Workers AI. The Worker calls monday GraphQL directly rather than wrapping `@mondaydotcomorg/monday-api-mcp`.

## Run it locally

Token from `mcli-demo` / keychain `monday-api-token-demo` (`cameron.monday.com`). Do not paste the zshenv `MONDAY_API_TOKEN`; that is monday.monday.com, account 5. Seed overlays `.dev.vars` for that reason. The Worker queries `me.account.slug` and refuses anything except `cameron`.

```bash
cp .dev.vars.example .dev.vars
npm install
npx wrangler d1 migrations apply steward --local
npm run seed          # prints BOARD_ALLOWLIST; put it in wrangler.jsonc vars and .dev.vars
npm run dev
```

Production secrets: `wrangler secret put MONDAY_API_TOKEN`, `DEMO_PASSWORD`, `COOKIE_ENCRYPTION_KEY`. Never commit `.dev.vars`.

Cursor: merge a `steward` entry into `~/.cursor/mcp.json` with only `"url": "https://steward.c-rogs.workers.dev/mcp"`. No static headers.

## Not in this build

monday webhook verification (UI webhooks have no HMAC), `triggerUuid` idempotency, GraphQL vs webhook drift, a `what_breaks` tool, a session complexity budget, and VECTOR memory. Next, not now.

## Questions after you try it

1. Why is official monday MCP an unsafe write surface even if every tool has `readOnlyHint` / `destructiveHint`?
2. Steward issues its own tokens and checks `aud`. Which RFC is that, and what confused-deputy does it stop?
3. Cursor documents elicitation. Why does `/approvals` still exist?
4. monday’s status mutation field is named `index`. Why is that not display order, and why reject `label` and colour on the way in?
5. `create_item` has no idempotency. Same `Idempotency-Key`, different payload: what happens?

<details>
<summary>Answers</summary>

1. Annotations are hints to the model. The host or the agent holds a monday token, `all_monday_api` is a GraphQL escape hatch, there is no tenant allowlist, no frozen schema, and no human gate. Steward’s handlers re-check `steward.commit` and will not call `change_multiple_column_values` unless the proposal is approved.

2. RFC 8707 (resource indicators). Audience is `https://steward.c-rogs.workers.dev/mcp`. A monday JWT at `/mcp` is `not_a_steward_token`. The monday credential never leaves the Worker.

3. Elicitation is a capability plus a protocol era. 2026-07-28 `input_required` needs a client that declared elicitation. Cursor often speaks 2025-era streamable HTTP, per-request, and cannot receive `elicitation/create`. Returning `inputRequired` on that path throws inside the SDK after the handler returns. Steward stores the proposal first and always offers `/approvals`. Confirming in elicitation still does not mutate monday; `commit_proposal` (or Approve & apply) does.

4. monday documents that `index` is the label id, not UI sort order and not the colour name. Palettes and strings change when someone edits the board. The frozen contract maps `Done` to `{index: N}`. Incoming `label` is translated then stripped. Colour keys fail closed.

5. Steward keys `(client_id, key)` in D1. Same key and same `args_hash` return the original result (no second monday call). Same key and a different hash return `idempotency_conflict`. monday will create two items; Steward will not.

</details>