No auth scheme survives a session bootstrap, which is the single blocker on retiring both bluesky-mcp and steam-ops' authenticated half #91

Open
opened 2026-08-19 23:03:24 +00:00 by coilyco-ops · 0 comments
Member

Filed by Olaf (ops seat, claude) after tracing why two bespoke MCPs cannot follow reddit-mcp onto a guardfile. Kai's stated goal is retiring reddit-mcp, bluesky-mcp, and steam-ops. Reddit is done. The other two are blocked on the same missing capability, and it is this one.

What exists

The grammar offers four auth schemes, all resolving a static value per request from env or SSM:

  • auth none - examples/reddit.mcp.kdl, examples/steam-storefront.mcp.kdl
  • auth bearer - examples/skillsmp.mcp.kdl, examples/sidecar.mcp.kdl
  • auth header-token - examples/forgejo-issues.mcp.kdl
  • auth query-param - examples/steam-web-api.mcp.kdl

Grepping docs/ for createSession, login, refresh.token, or a client-credentials exchange returns nothing. There is no scheme where the runtime performs a first request to obtain a credential and then presents the result on subsequent requests.

Why that blocks bluesky-mcp

Its deploy README says the manifest injects an existing Bluesky app password via ExternalSecret. AT Protocol turns an app password into a session through com.atproto.server.createSession, which returns an accessJwt (short-lived) and a refreshJwt. Every authenticated read presents the accessJwt, and it must be refreshed. No scheme above can express that.

The surface splits cleanly, which is the useful part. I probed public.api.bsky.app unauthenticated just now:

  • 200, beaverable today with auth none - app.bsky.actor.getProfile, app.bsky.feed.getAuthorFeed, app.bsky.feed.getPostThread, app.bsky.graph.getFollowers, app.bsky.graph.getFollows, app.bsky.actor.searchActors
  • 401 - app.bsky.feed.getTimeline, app.bsky.notification.listNotifications
  • 403 - app.bsky.feed.searchPosts

So of the source's 11 read tools, roughly 7 could ship as a credential-free guardfile immediately, the same auth none shape steam-storefront uses. get_home_timeline, list_notifications, search_posts, and Kai's likes need this issue closed first. (app.bsky.feed.getPosts I did not probe and is inferred public from its family.)

Why that blocks steam-ops only partially

Steam's web-api and storefront halves are already guardfiles shipped to both Sirens lanes. The remaining plane is client/PICS, and it needs two things this repo does not have: a session bootstrap, and a websocket. The websocket half is against a stated design invariant - docs/DESIGN.md has a section titled "Network HTTP, never stdio" and the runtime binds exactly one HTTP listener - so I am not asking for it here and do not think it should happen. Steam's PICS plane is likely a permanent serve-upstream case rather than a guardfile case. Its own failure is tracked at coilyco-gaming/steam-ops#13.

Naming it so this issue is not later read as covering steam end to end. Closing this unblocks bluesky. It does not retire steam-ops.

The shape worth considering

Reddit's retirement needed three upstream capabilities that did not exist when it started: raw-response (umbra#289), auth none (umbra#303), and extract as="feed-entries" (mcp-beaver#81). That is the pattern - each bespoke retirement surfaces the gap that was hiding inside the bespoke code. This is bluesky's.

Sketch, not a design:

auth session {
    bootstrap {
        path "/xrpc/com.atproto.server.createSession"
        body-field "identifier" env "BLUESKY_HANDLE"
        body-field "password"   env "BLUESKY_APP_PASSWORD"
    }
    present bearer from="json:accessJwt"
    refresh on-status=401
}

Open questions a design has to settle, listed because they are the hard part rather than the grammar:

  • Where the session lives. One process-wide session, or per request. serve-upstream already documents a one-long-lived-session decision for its own upstream, and the reasoning there may transfer.
  • Refresh trigger. On 401, on a parsed expiry, or on a fixed interval. On-401 needs an idempotent retry-once path and must not loop.
  • Failure shape. This is the one I care most about. steam-ops#13 is the cautionary case: a dead credential there produces an unbounded hang rather than an error, and I burned 8 minutes on a single call proving it. A session bootstrap that cannot complete must fail bounded and say which of "credential rejected" and "upstream unreachable" happened.
  • Startup versus lazy. Bootstrapping at startup fails fast and matches upstream mode's readiness behaviour. Bootstrapping lazily means the pod reports Ready with a dead credential, which is exactly the steam failure mode.
  • Whether the bootstrap response can leak. The accessJwt must never reach a tool result, a log line, or an error string.

Acceptance

  • A guardfile can declare an upstream whose credential is obtained by a request rather than read from env or SSM.
  • Bluesky's authenticated reads work through it, and the bootstrap credential never appears in a tool result, log, or error.
  • A rejected or unreachable bootstrap returns a bounded, sanitized error. It never hangs.
  • Readiness reflects bootstrap state, so a pod with a dead credential does not report Ready.
  • lint catches a malformed session block offline.

Not asked for here

Websocket or any non-HTTP transport. See the steam paragraph above.

Filed by Olaf (ops seat, `claude`) after tracing why two bespoke MCPs cannot follow reddit-mcp onto a guardfile. Kai's stated goal is retiring reddit-mcp, bluesky-mcp, and steam-ops. Reddit is done. The other two are blocked on the same missing capability, and it is this one. ## What exists The grammar offers four auth schemes, all resolving a **static** value per request from env or SSM: * `auth none` - examples/reddit.mcp.kdl, examples/steam-storefront.mcp.kdl * `auth bearer` - examples/skillsmp.mcp.kdl, examples/sidecar.mcp.kdl * `auth header-token` - examples/forgejo-issues.mcp.kdl * `auth query-param` - examples/steam-web-api.mcp.kdl Grepping `docs/` for `createSession`, `login`, `refresh.token`, or a client-credentials exchange returns nothing. There is no scheme where the runtime performs a **first** request to obtain a credential and then presents the result on subsequent requests. ## Why that blocks bluesky-mcp Its deploy README says the manifest injects an existing Bluesky **app password** via ExternalSecret. AT Protocol turns an app password into a session through `com.atproto.server.createSession`, which returns an `accessJwt` (short-lived) and a `refreshJwt`. Every authenticated read presents the `accessJwt`, and it must be refreshed. No scheme above can express that. The surface splits cleanly, which is the useful part. I probed `public.api.bsky.app` unauthenticated just now: * **200, beaverable today with `auth none`** - `app.bsky.actor.getProfile`, `app.bsky.feed.getAuthorFeed`, `app.bsky.feed.getPostThread`, `app.bsky.graph.getFollowers`, `app.bsky.graph.getFollows`, `app.bsky.actor.searchActors` * **401** - `app.bsky.feed.getTimeline`, `app.bsky.notification.listNotifications` * **403** - `app.bsky.feed.searchPosts` So of the source's 11 read tools, roughly 7 could ship as a credential-free guardfile immediately, the same `auth none` shape steam-storefront uses. `get_home_timeline`, `list_notifications`, `search_posts`, and Kai's likes need this issue closed first. (`app.bsky.feed.getPosts` I did not probe and is inferred public from its family.) ## Why that blocks steam-ops only partially Steam's web-api and storefront halves are already guardfiles shipped to both Sirens lanes. The remaining plane is client/PICS, and it needs **two** things this repo does not have: a session bootstrap, and a websocket. The websocket half is against a stated design invariant - `docs/DESIGN.md` has a section titled "Network HTTP, never stdio" and the runtime binds exactly one HTTP listener - so I am **not** asking for it here and do not think it should happen. Steam's PICS plane is likely a permanent `serve-upstream` case rather than a guardfile case. Its own failure is tracked at coilyco-gaming/steam-ops#13. Naming it so this issue is not later read as covering steam end to end. Closing this unblocks bluesky. It does not retire steam-ops. ## The shape worth considering Reddit's retirement needed three upstream capabilities that did not exist when it started: `raw-response` (umbra#289), `auth none` (umbra#303), and `extract as="feed-entries"` (mcp-beaver#81). That is the pattern - each bespoke retirement surfaces the gap that was hiding inside the bespoke code. This is bluesky's. Sketch, not a design: ``` auth session { bootstrap { path "/xrpc/com.atproto.server.createSession" body-field "identifier" env "BLUESKY_HANDLE" body-field "password" env "BLUESKY_APP_PASSWORD" } present bearer from="json:accessJwt" refresh on-status=401 } ``` Open questions a design has to settle, listed because they are the hard part rather than the grammar: * **Where the session lives.** One process-wide session, or per request. `serve-upstream` already documents a one-long-lived-session decision for its own upstream, and the reasoning there may transfer. * **Refresh trigger.** On 401, on a parsed expiry, or on a fixed interval. On-401 needs an idempotent retry-once path and must not loop. * **Failure shape.** This is the one I care most about. steam-ops#13 is the cautionary case: a dead credential there produces an **unbounded hang** rather than an error, and I burned 8 minutes on a single call proving it. A session bootstrap that cannot complete must fail bounded and say which of "credential rejected" and "upstream unreachable" happened. * **Startup versus lazy.** Bootstrapping at startup fails fast and matches upstream mode's readiness behaviour. Bootstrapping lazily means the pod reports Ready with a dead credential, which is exactly the steam failure mode. * **Whether the bootstrap response can leak.** The `accessJwt` must never reach a tool result, a log line, or an error string. ## Acceptance * A guardfile can declare an upstream whose credential is obtained by a request rather than read from env or SSM. * Bluesky's authenticated reads work through it, and the bootstrap credential never appears in a tool result, log, or error. * A rejected or unreachable bootstrap returns a bounded, sanitized error. It never hangs. * Readiness reflects bootstrap state, so a pod with a dead credential does not report Ready. * `lint` catches a malformed session block offline. ## Not asked for here Websocket or any non-HTTP transport. See the steam paragraph above.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/mcp-beaver#91
No description provided.