|
1 | 1 | ## Last session |
| 2 | +2026-08-17 (**an OAuth connection could be pinned to `read` forever — root-caused, fixed, and the repair path built**) — Fahim's report: Permissions set to "Managing the site", ChatGPT reporting `read` access and **83 of 160** tools, every Waggle write tool missing, and "edit operation fails" on `waggle-update-seo-meta`. Refreshing the connector changed nothing. **It was ours, on both sides.** → [#107](https://github.com/plugpressco/saddle/issues/107) / PR [#108](https://github.com/plugpressco/saddle/pull/108), and waggle [#181](https://github.com/plugpressco/waggle/issues/181) / PR [#182](https://github.com/plugpressco/waggle/pull/182). 594 tests (was 577), 0 lint errors. **Both PRs open, not merged** — over the 400-line ask-first threshold and OAuth code. |
| 3 | + |
| 4 | +**The chain, in order.** `get_tier()` is `min(site tier, credential ceiling)`; site tier was `admin`, ceiling was `read`, so `filter_adapter_tools_list()` dropped every ability above `read` at dispatch. The ceiling was `read` because **three** places named `saddle:read` as a constant, and the live one is the least obvious: `normalize_scope()` fell back to `DEFAULT_SCOPE` when the client sent no scope — and **ChatGPT sends none at all**, it registers via DCR and starts the flow with no `scope` parameter ([confirmed in OpenAI's own developer forum](https://community.openai.com/t/how-are-the-scopes-that-the-chatgpt-connector-requests-when-authorizing-the-mcp-server-determined/1363723)). The 401 challenge advertised `saddle:read` regardless of the site's level, and the consent screen had a branch for "asked for more than the site allows" and none for "asked for less". So every ChatGPT grant landed on read, permanently, and **reconnecting produced an identical grant** — the one remedy the UI suggested was the one that could not work. |
| 5 | + |
| 6 | +**The fix keeps the guardrail it looks like it breaks.** A scope still only ever *lowers* the site tier (pinned by a test). What changed is who proposes it: the consent screen now offers the levels up to `get_site_tier()` and clamps the posted choice server-side, and the authorize endpoint falls back to the site's scope **only when the client sent no scope at all** — an explicit `saddle:read` is still granted exactly that, because widening a request a client actually made is a spec violation and some clients compare the scope they get back. Plus `set_grant_scope()` + `POST /oauth-connections/<id>` + a level picker on Connect, so the connection that already exists is repairable without a reconnect. |
| 7 | + |
| 8 | +**Two traps found inside the fix.** `Saddle_OAuth_Bearer::$grant` never held a grant — it held the access-token record, whose `scope` is frozen for `ACCESS_TTL`. Rewriting only the grant would have made a level change appear to do nothing for an hour, so `set_grant_scope()` rewrites both and the property is now `$token_record`. And `tier_ceiling()` now takes the *lower* of token and grant scope, so lowering a level lands on the next request while raising still needs the token rewritten — strict in the safe direction. |
| 9 | + |
| 10 | +**Waggle's half.** The bundled `seo-write-article` skill told the agent to call `waggle/update-seo-meta` and six more like it. Saddle re-registers Waggle's abilities wrapped, so the only callable name is `saddle/waggle-*` — the agent was looking up tools that were not in its list, and would have failed even at the write tier. The existing test could not have caught it: every bare name is a substring of its wrapped form, so `assertStringContainsString( 'waggle/get-aeo-score' )` passed before and after. There is now one that checks for the bare form directly. |
| 11 | + |
| 12 | +**Also swept:** `update_settings()` read `get_json_params()` only, so a form-encoded `tier` passed the enum, passed `can_manage`, returned 200 and saved nothing — invisible from the dashboard, indistinguishable from "it didn't stick", and this codebase already works around one host that rewrites bodies. The `.pot` was stale against 1.0.0-rc3 again, missing far more than this change. |
| 13 | + |
| 14 | +**Open question for the live test, not a Saddle bug:** OpenAI's [developer-mode docs](https://developers.openai.com/api/docs/guides/developer-mode) say write actions work on every eligible plan with confirmation; several secondary sources and this file's own line below say fully write-capable custom connectors are Business/Enterprise/Edu only. The Connect tab now says so out loud. Settle it on the round-trip and record the answer here. |
| 15 | + |
| 16 | +## Previous session |
2 | 17 | 2026-08-16 (**the four pillars swept, Mark's ChatGPT bug root-caused and fixed, and every open PR resolved**) — Fahim's ask opened as "context, guardrails, tools and a system must work smoothly", turned into Mark Roach's ChatGPT report mid-session, and ended with "merge now" on the whole open-PR backlog. Eleven PRs merged (#90, #92, #94, #96, #98, #99, #66, #68, #69, #100, #101, #102); #70 closed as already-landed; **zero open PRs**. 575 tests, 0 lint errors. |
3 | 18 |
|
4 | 19 | **Mark's bug is fixed, and it was ours.** His two-client experiment isolated it: Claude Desktop via mcp-remote worked end to end against staging.kesuk.net while ChatGPT completed OAuth and then reported no callable actions. That rules out abilities, tiers, OAuth and the endpoint, leaving the handshake. `Saddle_MCP` violated three Streamable-HTTP MUSTs, and one sits between "connected" and `tools/list`: a notification must be answered **202 with no body**, and Saddle answered `200` with the JSON literal `null`. mcp-remote shrugs; a strict client treats the handshake as unfinished and never asks what tools exist. Also fixed: GET now 405 (was 404), DELETE 405, unsupported `MCP-Protocol-Version` 400, unparseable body 400, and `resources/*`/`prompts/list` answer empty lists instead of Method-not-found. **This was never only Mark** — since `bff1a99` the .org zip has no adapter, so that transport is the only one a .org install will ever have. |
@@ -269,7 +284,7 @@ Earlier 2026-07-12 — **Audit-backlog execution (P0–P4)**. Cleared the audit |
269 | 284 | - **Delete the v1.1.0 GitHub release + tag (one command):** `gh release delete v1.1.0 --repo plugpressco/saddle --cleanup-tag --yes && git tag -d v1.1.0`. Still outstanding, and #69 tried to reintroduce 1.1.0 this session — reverted in four places, including a `Stable tag` that was not a conflict and would have shipped silently. |
270 | 285 | - **Run `php scripts/revendor-wp-mcp.php --check` before any release.** New pre-release guard: it exits non-zero if the vendored library was refreshed without re-applying Saddle's text domain. |
271 | 286 | - **SUBMIT to WordPress.org.** Everything code-side is done and this session removed one of the reviewer-facing surfaces (#86). Fahim's three steps are in `WPORG-SUBMISSION.md`. Rebuild first: `npm run build && npx grunt build` — never `grunt release`, it bumps the version. |
272 | | -- **Live ChatGPT round-trip on plugpress.co** — still the real gate for the OAuth work, and now also the confirmation that the handshake fix holds against the client that exposed it. |
| 287 | +- **Live ChatGPT round-trip on plugpress.co** — still the real gate for the OAuth work, and now carrying three questions at once: the handshake fix against the client that exposed it, [#108](https://github.com/plugpressco/saddle/pull/108)'s level picker (set the existing connection to "Managing the site" on Connect, refresh the connector, expect ~160 tools and `saddle-waggle-update-seo-meta` present), and the plan question — if the write tools are *listed* and ChatGPT still declines to call them, that is the Business/Enterprise/Edu gate, not us. Record the answer in this file either way. |
273 | 288 | - **CI PHPUnit still red on GitHub Actions** (no WP core in the runner). Worth its own PR so future PRs get a real green; "green" currently means someone ran the suite locally and said so. |
274 | 289 |
|
275 | 290 | ## Blockers / open questions |
|
0 commit comments