diff --git a/agents.md b/agents.md index 1da167c8ee1a81231123a89521731f4ab5dc0fbe..8fffaeaff9000b783c7f2a7e3ddf761c46277bd1 100644 --- a/agents.md +++ b/agents.md @@ -33,10 +33,12 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma --- -## Command surfaces: `HtmlUiAction` vs `RpcCommand` +## Command surfaces: `HtmlUiAction` vs `RpcCommand` vs MCP - **`RpcCommand` / `POST /api/v0/rpc`** (`types/src/lib.rs`, `server/src/api/rpc.rs`): **Bearer-authenticated** JSON API for CLI, automation, and programmatic clients. Durable effects go through here (append to event log, then `apply_event`). +- **ChatGPT / Codex MCP (`POST /mcp`)** (`server/src/mcp/`): Streamable-HTTP JSON-RPC for the Plugins Directory. Tools call `dispatch_rpc` (same handlers as `POST /api/v0/rpc`). Public reads are `noauth`. Writes (`post_sorter`, `redact_post`) require `Authorization: Bearer slug_…` (issued as the OAuth access token). OAuth 2.1 + PKCE lives at `/oauth/authorize` + `/oauth/token` and reuses Google login; well-known docs are `/.well-known/oauth-protected-resource` and `/.well-known/oauth-authorization-server`. Domain verification token is `GET /.well-known/openai-apps-challenge` from `SLUG_OPENAI_APPS_CHALLENGE`. Do not add a parallel write path. Do not reuse `POST /ui` + `eval` morph as a ChatGPT widget. + - **Forum thread tags on write:** `validate_thread_tag` (`types/src/paths.rs`) canonicalizes then rejects empty tags and any tag containing `/` (would break `/t/:tag` routing). The new-thread form already constrains the charset client-side (`pattern="[a-z0-9_\\-]{1,64}"`); server write paths (`WriteCmd::Post` / `SystemIngest`, and UI `PostIngest` / `CheckIngest` / `VoteComparePost`) enforce the slash rule. Read/replay still uses `canonicalize_tag` only so historical tags keep resolving. System import tags use `:` instead of `/` (e.g. `import:https:::github.com:org:repo`). - **`HtmlUiAction` / `POST /ui`** (`server/src/html/ui_action.rs`, `server/src/api/ui_html.rs`): **Browser session** (cookie) UI commands. Payload is `__rpc__` + form fields. Most responses are **JS morphs**; some actions return **HTTP redirects** (see below). diff --git a/ideas/chatgpt-mcp-app.md b/ideas/chatgpt-mcp-app.md new file mode 100644 index 0000000000000000000000000000000000000000..9defb8ffa3040dde29af9062ea070edaa915837c --- /dev/null +++ b/ideas/chatgpt-mcp-app.md @@ -0,0 +1,411 @@ +# ChatGPT marketplace MCP app for slug + +Research note (2026-08-26). How OpenAI's ChatGPT / Codex plugin directory works, and what it would take for slug.social to ship as a first-party product integration there. + +This is **not** the 2023 GPT Store / ChatGPT Plugins beta. That stack was wound down. The current product is: + +- **Apps in ChatGPT** (preview since Oct 2025; Business / Enterprise / Edu later) +- Built with the **Apps SDK**, which is MCP plus optional UI +- **Submitted and published as plugins** into a **universal Plugins Directory** shared by ChatGPT and Codex + +Canonical docs: + +- [Apps SDK](https://developers.openai.com/apps-sdk) +- [Build an MCP server](https://developers.openai.com/apps-sdk/build/mcp-server) +- [Authenticate users](https://developers.openai.com/plugins/build/auth) +- [Submit plugins](https://developers.openai.com/plugins/deploy/submission) +- [MCP review requirements](https://developers.openai.com/plugins/deploy/app-review) +- [Connect and test](https://developers.openai.com/plugins/deploy/connect-chatgpt) +- [Company-knowledge `search` / `fetch`](https://developers.openai.com/api/docs/mcp) +- [App guidelines](https://developers.openai.com/apps-sdk/app-submission-guidelines) + +--- + +## What a marketplace listing actually is + +A public ChatGPT "app" is a **plugin** that can contain: + +1. **An MCP server** (live tools, auth, optional UI) — this is the product-integration path. +2. **Skills** (`SKILL.md` plus files) — reusable workflows, either uploaded or imported from the MCP server at scan time. +3. **Both**. + +Users find it in the Plugins Directory (search by name, or a direct listing URL). Enhanced homepage placement is OpenAI-selected; you cannot request it. After approval you still have to hit **Publish** — review does not auto-list. + +ChatGPT also converts approved apps into **Codex plugins**, so one submission covers both hosts. + +There are three shapes you can submit: + +| Shape | When to use for slug | +| --- | --- | +| MCP-only | First ship. Tools wrap garden / forum / search. UI optional. | +| Skills-only | Weak fit. Slug needs live rankings and writes, not a static checklist. | +| Skills + MCP | Best later. Import `GUIDE.sorter` as a skill that teaches the model how to vote, then call tools. | + +Most listings use a **universal MCP URL**: one HTTPS endpoint for every user. Template URLs (`https://{workspace}.example.com/mcp`) are only for trusted partners with per-tenant hosts. Slug is a single public site plus private rooms behind one origin, so **universal `https://slug.social/mcp`** is the right choice. + +--- + +## Runtime architecture + +``` +ChatGPT / Codex + │ streamable HTTP (usually POST https://slug.social/mcp) + │ Authorization: Bearer + ▼ +slug MCP server (resource server) + │ tools/list, tools/call, optional resources, optional skills/list + ▼ +existing reducer / RpcCommand / events.jsonl +``` + +The MCP server is **not** a second product. It is another command surface next to: + +- `POST /api/v0/rpc` (`RpcCommand`) — CLI / automation +- `POST /ui` (`HtmlUiAction`) — browser morph UI + +Same durability, same authz, different wire format. + +### Transport + +Public plugins must speak **MCP streamable HTTP** on a stable HTTPS origin. Typical path is `/mcp`. Local / ngrok / Secure MCP Tunnel is fine for developer-mode testing and **not** acceptable for submission. + +ChatGPT presents an OpenAI-managed **mTLS client cert** (`SAN dnsName = mtls.prod.connectors.openai.com`). You can require that chain to prove the caller is ChatGPT. That authenticates the **host**, not the **user**. User identity is OAuth 2.1. + +If you IP-allowlist, use OpenAI's published connector egress ranges and refresh them automatically. An allowlist does not replace auth. + +### What the server advertises + +On `initialize`: + +- `name` / `version` (stable product name, e.g. `slug-social`) +- `instructions` — cross-tool guidance, keep the important part in the first 512 characters (required sequences, public-vs-private rooms, "ask the human before voting") +- tools with title, description, input schema, output schema, annotations, `securitySchemes` +- optional UI resources +- optional `io.modelcontextprotocol/skills` extension + +On each tool result: + +- `structuredContent` — data the model will chain on (ids, ranks, URLs) +- `content` — short text the model can quote +- `_meta` — host-only (widget state, `mcp/www_authenticate`). Hidden from the model. Not a place to put secrets. + +Do not leak bearer tokens, session ids, or debug payloads in tool results. Reviewers reject undisclosed PII. + +### Tool annotations (reviewers check these against real behavior) + +| Annotation | Slug meaning | +| --- | --- | +| `readOnlyHint: true` | Fetch / list / rank / search / `check` dry-run. No event log append. | +| `readOnlyHint: false` | `Post`, `PostRedact`, room grant/revoke/delete, graduate, invite mint. | +| `openWorldHint: true` | Anything that changes **public** internet-visible state: public forum post, public garden vote, thread graduate onto the public site. | +| `openWorldHint: false` | Private-room writes only. | +| `destructiveHint: true` | `PostRedact`, `RoomDelete`, `RoomRevoke`. Irreversible or hard to undo. | + +A public `forum post` is both a write and an open-world action. Annotate it that way. A justification that says "functionally read-only" will not override `readOnlyHint: false`. + +### Company knowledge / Deep Research + +If you implement the standard **`search`** and **`fetch`** tools (and mark other reads `readOnlyHint: true`), ChatGPT can treat the plugin as a company-knowledge source. + +Required shapes: + +- `search({ query })` → `{ results: [{ id, title, url }] }` +- `fetch({ id })` → `{ id, title, text, url, metadata? }` + +Citations only appear when `url` is a non-empty absolute URL. Slug already has shareable GET URLs (`/t/:tag`, garden item paths, `/-/https://…`). Use those as `url`. Keep internal ids in `id`. + +Slug already has `RpcCommand::Search` plus item/thread/post fetch. Mapping that pair onto the company-knowledge schema is the highest-leverage read surface. + +--- + +## Optional UI (widgets) + +Custom UI is **optional**. Tools must work without it (Codex and text-only clients will not render a widget). + +If you add UI: + +- Register an MCP Apps resource (HTML/JS/CSS bundle). +- Point selected tools at it with `_meta.ui.resourceUri` (legacy alias: `_meta["openai/outputTemplate"]`). +- The bundle runs in a **sandboxed iframe**. Talk to the host over the MCP Apps JSON-RPC `postMessage` bridge (`ui/initialize`, `ui/notifications/tool-result`, `tools/call`, `ui/message`). +- Prefer the standard bridge. Use `window.openai` only for ChatGPT-only extras (files, `requestModal`, Instant Checkout). +- Declare a **CSP** that lists every origin the iframe fetches. + +**Do not reuse the current browser UI.** The web app is `POST /ui` + `eval` of server-emitted morph JS (`agents.md`). ChatGPT's iframe CSP will not allow that, and it is the wrong contract anyway. Widgets must be self-contained HTML that consume `structuredContent`. + +Recommended split (OpenAI's own guidance): + +1. **Data tools** return ranks / pairs / thread items. No widget attached. +2. **Render tools** (`render_rank_widget`, `render_pair_widget`) take ids and attach the UI resource. + +Natural slug widgets later, not in v1: + +- ranked list for a garden parent +- pairwise compare card (the fullscreen `/vote/compare` idea, as an inline/fullscreen widget) +- thread excerpt with cite-able post URLs + +v1 should ship **MCP-only, no UI**. Screenshots are only for plugins that have UI; empty screenshot slots are required if you have none. + +--- + +## Authentication (the hard part) + +Slug today: + +- Human principal from **Google OAuth** +- Durable credential is `slug__` (cookie `slug_session` or `Authorization: Bearer`) +- CLI agents add a **delegate** `uuid:rig:provider/model` on write +- Pending sessions are **RAM-only** +- Tokens live in the event log + +ChatGPT will **not** send a `slug_…` key that the user pasted. For any private data or write tool it runs **OAuth 2.1 authorization-code + PKCE (S256)** as specified by MCP authorization. ChatGPT is the OAuth **client**. Your MCP endpoint is the **resource server**. Token minting belongs on an **authorization server**. + +### What ChatGPT expects + +1. **Protected resource metadata** on the MCP host: + + `GET https://slug.social/.well-known/oauth-protected-resource` + + ```json + { + "resource": "https://slug.social/mcp", + "authorization_servers": ["https://slug.social"], + "scopes_supported": ["slug.read", "slug.write"] + } + ``` + +2. **AS metadata** at `/.well-known/oauth-authorization-server` or OIDC `/.well-known/openid-configuration`: + + - `authorization_endpoint`, `token_endpoint` + - `code_challenge_methods_supported` **must include `S256`** (hard fail otherwise) + - `token_endpoint_auth_methods_supported` intersecting ChatGPT's CIMD (`none` and/or `private_key_jwt`) + - optional `client_id_metadata_document_supported: true` (preferred) + - optional `registration_endpoint` (DCR fallback) + - optional `authorization_response_iss_parameter_supported: true` plus `iss` on every auth response → ChatGPT uses the stable redirect `https://chatgpt.com/connector_platform_oauth_redirect` + +3. **Echo `resource`** from authorize + token requests into the access token `aud` (or equivalent). The MCP server must verify `iss`, `aud`, `exp`, scopes on every call. + +4. **Per-tool `securitySchemes`**: + + - `{ type: "noauth" }` — public garden/forum reads + - `{ type: "oauth2", scopes: ["slug.write"] }` — posts, votes, redacts, rooms + - both — optional linking (anonymous public read, login unlocks writes) + +5. **Runtime challenge**. Metadata alone is not enough. An unauthenticated write must return an error result with: + + `_meta["mcp/www_authenticate"]` containing `error` and `error_description`, pointing at the resource metadata URL. + + That pair is what pops ChatGPT's linking UI. + +6. **Workspace domain restrictions** (Enterprise): AS must advertise `openid` + `email`, and a UserInfo endpoint that returns `email` + `email_verified: true`. + +7. **Reviewer demo account**: no MFA, no email/SMS step, no private network. They will reject otherwise. + +### Client registration + +Prefer **CIMD** (Client ID Metadata Documents). ChatGPT sends an HTTPS URL as `client_id` (`https://chatgpt.com/oauth/client.json` or a callback-id-specific document). Your AS fetches it, allowlists the redirect, and treats that URL as the client id. No per-connection client explosion. + +DCR (`registration_endpoint`) still works; ChatGPT registers once per connection. Harder to administer. + +ChatGPT does **not** do client-credentials, service-account, or "paste your API key" for published plugins. + +### What this means for slug's existing Google login + +Google is an identity provider for **humans logging into slug**. ChatGPT needs slug (or a hosted IdP in front of slug) to be an **OAuth 2.1 authorization server** that ChatGPT can talk to. + +Two workable designs: + +**A. Slug becomes a thin AS (recommended if we want first-party control)** + +- `authorization_endpoint` reuses the existing Google login + username-choose flow, then issues a **short-lived JWT** (or the existing `slug_…` token encoded as a JWT) with `aud=https://slug.social/mcp`. +- `token_endpoint` does PKCE verify + refresh. +- Publish CIMD support with `token_endpoint_auth_methods_supported: ["none"]` (public client + PKCE) or `private_key_jwt`. +- MCP handlers call the same `verify_token` / principal resolution the RPC layer uses. + +This is real protocol work (discovery docs, PKCE, refresh, `resource`/`aud`, CIMD fetch). OpenAI explicitly recommends **not** writing an AS from scratch if you can avoid it. + +**B. Put Auth0 / similar in front** + +- Auth0 already has MCP + CIMD guides. +- After Google (or Auth0 social) login, a slug-side hook still has to mint or bind a slug principal + durable token. +- Faster protocol compliance, extra vendor, still need a stable mapping from IdP subject → slug username. + +**Do not** try to have ChatGPT complete slug's current `/auth/login` redirect and then scrape a `slug_…` cookie. That is not the MCP client contract. + +### Identity / delegates in ChatGPT + +`GUIDE.sorter` says writes from non-browser clients need `--delegate '::'`, and the human principal comes from OAuth. + +In ChatGPT: + +- Human principal = the linked slug user (from OAuth). +- Delegate should be a **server-assigned** binding for that ChatGPT user + conversation/host, e.g. `:chatgpt:openai/`, stored the same way CLI binds are stored. +- Do not ask the model to invent or remember a UUID. That is impersonation-prone (the guide already forbids writing the UUID into shared memory). +- Browser-style human posts (no delegate) are also valid if we treat ChatGPT as "the human is present and confirming." That matches the "ask your human before voting" rule better than a silent agent bind. + +Recommendation: **v1 writes are human-principal posts** (like the website). Add ChatGPT-as-rig delegates only if we want agent continuity across chats. + +--- + +## How users install and use it + +### Developer mode (before submission) + +1. ChatGPT → Settings → Security and login → Developer mode. +2. [chatgpt.com/plugins](https://chatgpt.com/plugins) → plus → name, description, MCP URL `https://…/mcp` (or a Secure MCP Tunnel id). +3. Review discovered tools. +4. New chat → enable the connection from the tools / More menu → prompt. +5. After metadata changes: open the connection → **Refresh** (published plugins do **not** live-refresh metadata; they use the reviewed snapshot). + +Also useful: `npx @modelcontextprotocol/inspector` against streamable HTTP, and API Playground → Tools → Add → MCP Server. + +### Public directory (after publish) + +User searches "slug" (or opens the listing URL) → install → first write tool triggers OAuth linking → ChatGPT calls tools when the prompt matches descriptions / starter prompts. + +Discovery quality depends on tool descriptions, server `instructions`, and starter prompts. Treat those as product copy. + +--- + +## Submission and review + +Prerequisites: + +- OpenAI Platform org with **identity verification** (individual or business). Mismatch with the public listing name is a reject. +- Role permission **Apps Management = Write** (`api.apps.write`). +- Global-residency project (EU-residency projects cannot submit MCP plugins today). +- Public production MCP URL, not a tunnel. +- Domain verification: portal token at `https://slug.social/.well-known/openai-apps-challenge` (exact token only, not JSON). Parent host is allowed if the MCP host is a subdomain. +- Privacy policy, terms, support, website URLs that match the publisher. +- **5 positive + 3 negative** test cases with expected tool, result shape, and fixture data. +- Starter prompts. +- If OAuth: reviewer credentials that work without MFA. + +Flow: + +1. Create plugin → "With MCP". +2. Paste universal MCP URL, auth details, CSP (if UI). +3. **Scan Tools** — dashboard snapshots tools, schemas, annotations, `securitySchemes`, `_meta`, UI resources, `instructions`, and any MCP-exported skills. +4. Fill listing + attestations → Submit for review. +5. Wait (no expedite). +6. On approval, **Publish**. Then it is searchable in the directory. + +Metadata is a **versioned snapshot**. Changing tool names, schemas, annotations, instructions, or UI resource URIs requires scan → new version → review → publish. Live result payloads can change without a resubmit if the published contract stays compatible. Changing scheme/host/port of the MCP origin requires a **new plugin**, not a new version. + +Common rejects that slug should pre-empt: + +- Cannot reach `/mcp` or reviewer cannot log in. +- Test cases don't match actual tool selection / output. +- Tool results include tokens, internal ids, or PII not in the privacy policy. +- `readOnlyHint` / `openWorldHint` / `destructiveHint` don't match behavior. +- App is an **unofficial connector** to a third party. Slug's GitHub resolver is a first-party feature of slug, but a ChatGPT app whose *primary* job is "talk to GitHub through us" would be rejected. Keep GitHub import out of the v1 tool list. + +--- + +## Mapping slug onto tools + +Do **not** expose raw `RpcCommand` as one mega-tool. OpenAI wants one tool per user goal. + +### v1 — public read + authenticated write + +| Tool | Goal | Auth | Annotations | Existing code | +| --- | --- | --- | --- | --- | +| `search` | Find items, threads, posts | noauth | read-only | `RpcCommand::Search` | +| `fetch` | Open one search hit by id | noauth | read-only | `GetGardenItem` / `GetForumThread` + post id | +| `list_threads` | What's circulating | noauth | read-only | `ListForumThreads` | +| `get_thread` | Read a thread page | noauth | read-only | `GetForumThread` | +| `get_rank` | Ranked children under a path | noauth | read-only | `GetGardenRank` / `GetGlobalRank` | +| `get_item` | Item body + related threads | noauth | read-only | `GetGardenItem` | +| `get_pair` | Next comparison in a scope | noauth (pair is public) | read-only | `GetPair` | +| `check_sorter` | Dry-run a `.sorter` doc | noauth | read-only | `Check` | +| `post_sorter` | Publish a comparison / definition | oauth2 | write, **open-world** if `room=public` | `Post` | +| `redact_post` | Tombstone own post | oauth2 | write, destructive | `PostRedact` | + +Return absolute `https://slug.social/…` URLs on every structured object so the model can cite and the user can open the real site. + +Private rooms (`RoomList`, grants, graduate) can wait. They force OAuth on almost every call and expand review surface. If we add them later, `openWorldHint` is false until graduate. + +### Skills (v1.5) + +Import a static skill from the MCP server (`skills/list` + `resources/read`, SEP-2640 subset): + +- `name`: `slug-compare` +- Teaches: get a pair → ask the human → write a `.sorter` doc (item bodies, `{ reason }`, `3:1` / `>` / `=`) → `check_sorter` → `post_sorter` on a thread tag +- Source of truth is already `cli/GUIDE.sorter` + +Scan Tools snapshots skills; live edits do not update the published plugin until you scan + resubmit. + +Limits: 5 skills, 100 files each, 256 KiB `SKILL.md`, 5 MiB per skill. + +### What not to expose in v1 + +- GitHub resolver buttons (`ResolveExternal`) — third-party API, review risk, not the core loop +- Room admin / invite mint (RAM-only invites also make a poor reviewer story) +- `CopyGardenRank` / HUD / theme — browser-only +- Raw event log / health internals + +--- + +## Implementation sketch (when we build it) + +Keep MCP **inside `slugsocial-server`**, not a Node sidecar. One process, one event log, one principal verifier. Official examples are TypeScript/Python; the protocol is HTTP + JSON-RPC and Rust can speak it (or we vendor a small streamable-HTTP handler). + +New routes on the existing Axum app: + +- `POST /mcp` (and GET/DELETE as the transport requires) — streamable HTTP +- `GET /.well-known/oauth-protected-resource` +- `GET /.well-known/oauth-authorization-server` (if slug is the AS) +- `GET/POST` authorize + token (if slug is the AS) +- `GET /.well-known/openai-apps-challenge` — static token from env for the portal + +Handlers should call the same functions `handle_rpc_batch` already uses, then wrap `RpcResult` as `structuredContent`. Do not add a parallel write path. + +Server `instructions` (draft): + +> Slug is a garden (path-addressed ontology + pairwise rank centrality) and a forum (bump-ordered threads). Read tools work anonymously on room `public`. Before posting a comparison, call `get_pair` or `get_item`, ask the human for their view, draft a `.sorter` document, call `check_sorter`, then `post_sorter`. Do not invent delegate UUIDs. Cite the `url` fields returned by tools. + +Deploy: same Fly app (`slug.social`). No new origin — changing origin later means a new plugin listing. + +--- + +## Fit and risks + +**Why this is a good product surface** + +- Slug is already designed for "human + model write a comparison together." ChatGPT is that loop without `npx slugsocial`. +- Public garden/forum can start **anonymous read**, which is the easy half of MCP. +- Shareable URLs already exist for citations. +- The Plugins Directory is how a non-CLI audience finds the site. + +**Why it is not a weekend wrapper** + +- OAuth 2.1 AS (or Auth0) is new infrastructure. Current Google login + `slug_…` bearer is the wrong client protocol. +- Review is a real gate: verified identity, privacy policy, 8 test cases, hint accuracy, no MFA demo account. +- The morph/`eval` web UI cannot be the widget. +- Public posts are open-world writes; ChatGPT will confirm them. That is correct and must be designed for. +- Official policy forbids unofficial third-party connectors. Keep the app about **slug**, not GitHub. + +**Monetization** + +Not relevant yet. Instant Checkout / Agentic Commerce is beta for selected marketplaces. Digital goods must use external checkout on your own domain. Slug has no checkout today. + +--- + +## Suggested sequence + +1. Add `/mcp` with the v1 **read** tools (`search`, `fetch`, `get_rank`, `get_item`, `get_pair`, `list_threads`, `get_thread`, `check_sorter`). No auth. Hit it with MCP Inspector. +2. Connect in ChatGPT developer mode against production or a public preview URL. +3. Add OAuth 2.1 (Auth0 or slug-as-AS) and `post_sorter` / `redact_post`. +4. Write privacy/terms pages + reviewer account. +5. Optional: import `slug-compare` skill from `GUIDE.sorter`. +6. Submit universal plugin. Widgets only after the text tools feel good. + +## Implementation status (this branch) + +v1 is in `server/src/mcp/`: + +- `POST /mcp` — JSON-RPC `initialize`, `tools/list`, `tools/call` +- Read tools + `post_sorter` / `redact_post` via `dispatch_rpc` +- OAuth 2.1 + PKCE at `/oauth/authorize` + `/oauth/token` (Google login, access token is `slug_…`) +- Well-known metadata + `/.well-known/openai-apps-challenge` + +Still later: ChatGPT developer-mode connect, reviewer account, privacy/terms, optional `slug-compare` skill, widgets. diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs index 01c02f50c19bcd62c7f1717f9b927f203c3164d0..3f0a369abf7966f50a1fbf41994bf7f2bc70932b 100644 --- a/server/src/api/auth.rs +++ b/server/src/api/auth.rs @@ -115,10 +115,18 @@ fn redirect_with_session_cookie( path_and_query: &str, bearer: &str, jar: &CookieJar, +) -> Response { + redirect_absolute_with_session_cookie(&format!("{public_url}{path_and_query}"), bearer, jar) +} + +fn redirect_absolute_with_session_cookie( + location: &str, + bearer: &str, + jar: &CookieJar, ) -> Response { let mut res = Response::builder() .status(StatusCode::TEMPORARY_REDIRECT) - .header(header::LOCATION, format!("{public_url}{path_and_query}")) + .header(header::LOCATION, location) .body(Body::empty()) .unwrap(); let headers = res.headers_mut(); @@ -282,6 +290,7 @@ pub async fn get_join_invite( provider_id: None, redeem_invite: Some(token), redirect_next: redirect_next.clone(), + mcp_oauth: None, complete: None, }; state @@ -467,10 +476,27 @@ pub async fn get_auth_callback( } Ok(Ok(())) => {} } - let redirect_to = - safe_local_redirect(s.redirect_next.as_deref()).unwrap_or_else(|| "/".to_string()); let cookie_bearer = bearer.clone(); - s.complete = Some((username, bearer)); + s.complete = Some((username.clone(), bearer)); + drop(sessions_write); + if let Some(mcp_loc) = crate::mcp::oauth::finish_mcp_oauth_if_pending( + &state, + &q.state, + &username, + &cookie_bearer, + ) + .await + { + return redirect_absolute_with_session_cookie(&mcp_loc, &cookie_bearer, &jar) + .into_response(); + } + let redirect_to = { + let sessions_read = sessions.read().await; + sessions_read + .get(&q.state) + .and_then(|s| safe_local_redirect(s.redirect_next.as_deref())) + .unwrap_or_else(|| "/".to_string()) + }; return redirect_with_session_cookie(&public_url, &redirect_to, &cookie_bearer, &jar) .into_response(); } @@ -596,13 +622,24 @@ pub async fn post_choose_username( Ok(Ok(b)) => b, }; - let redirect_to = { + { let mut sessions_write = sessions.write().await; let s = sessions_write .get_mut(&form.session) .expect("session checked above"); s.complete = Some((canon_user.clone(), bearer.clone())); - safe_local_redirect(s.redirect_next.as_deref()) + } + if let Some(mcp_loc) = + crate::mcp::oauth::finish_mcp_oauth_if_pending(&state, &form.session, &canon_user, &bearer) + .await + { + return js_signed_in_fragment(&bearer, &jar, &mcp_loc).into_response(); + } + let redirect_to = { + let sessions_read = sessions.read().await; + sessions_read + .get(&form.session) + .and_then(|s| safe_local_redirect(s.redirect_next.as_deref())) .unwrap_or_else(|| "/auth/complete".to_string()) }; @@ -633,6 +670,7 @@ pub async fn get_web_login( provider_id: None, redeem_invite: None, redirect_next: redirect_next.clone(), + mcp_oauth: None, complete: None, }; state @@ -694,6 +732,7 @@ pub async fn post_pending_session( provider_id: None, redeem_invite: None, redirect_next: None, + mcp_oauth: None, complete: None, }; let sessions = pending_sessions(&state); diff --git a/server/src/api/helpers.rs b/server/src/api/helpers.rs index ac6c9052ab618aa77294a24d5881d25038733567..d2703f105bc8a99c98a75e16304528b4c806f5ac 100644 --- a/server/src/api/helpers.rs +++ b/server/src/api/helpers.rs @@ -1,21 +1,26 @@ -use axum::{ - http::StatusCode, - response::IntoResponse, - Json, -}; +use axum::{http::StatusCode, response::IntoResponse, Json}; use sha2::{Digest, Sha256}; use slug_types::paths::GardenItemUrl; use slug_types::ItemId; use slug_types::*; use std::collections::HashMap; -use crate::{ - canonical_path::canonicalize_item, - ranking::connected_components_from_voted_pairs, -}; - -pub fn api_error(status: StatusCode, error: impl Into, hint: Option) -> axum::response::Response { - (status, Json(ApiError { ok: false, error: error.into(), hint })).into_response() +use crate::{canonical_path::canonicalize_item, ranking::connected_components_from_voted_pairs}; + +pub fn api_error( + status: StatusCode, + error: impl Into, + hint: Option, +) -> axum::response::Response { + ( + status, + Json(ApiError { + ok: false, + error: error.into(), + hint, + }), + ) + .into_response() } pub fn sha256_hex(s: &str) -> String { @@ -24,6 +29,16 @@ pub fn sha256_hex(s: &str) -> String { format!("{:x}", hasher.finalize()) } +/// Public origin for OAuth metadata, MCP resource ids, and login redirects. +pub fn public_url() -> String { + std::env::var("SLUG_PUBLIC_URL") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "http://127.0.0.1:8080".to_string()) + .trim_end_matches('/') + .to_string() +} + pub fn now_ms() -> i64 { let t = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -48,7 +63,10 @@ pub fn parse_parent_specs(parent: Option<&String>) -> Vec { if s.is_empty() { return vec![]; } - s.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect() + s.split(',') + .map(|x| x.trim().to_string()) + .filter(|x| !x.is_empty()) + .collect() } /// Validate multi-parent garden scopes against [`ContentState`]: every explicit parent path must be a @@ -63,7 +81,9 @@ pub fn validate_garden_parent_scope_paths( return Ok(()); } let none_exist = specs.iter().all(|spec| { - let Some(canon) = ItemId::parse(spec) else { return true }; + let Some(canon) = ItemId::parse(spec) else { + return true; + }; !content.items.contains(&canon) && !content.item_children.contains_key(&canon) }); if none_exist { @@ -142,13 +162,24 @@ pub fn pick_random_distinct_item_pair(items: &[ItemId]) -> Option<(ItemId, ItemI pub fn is_pair_voted(group: &crate::reducer::GroupState, a: &str, b: &str) -> bool { let a_key = ItemId::parse(a).unwrap_or_else(|| ItemId::opaque(a.to_string())); let b_key = ItemId::parse(b).unwrap_or_else(|| ItemId::opaque(b.to_string())); - let Some(&a_idx) = group.item_to_idx.get(&a_key) else { return false; }; - let Some(&b_idx) = group.item_to_idx.get(&b_key) else { return false; }; - let (i, j) = if a_idx < b_idx { (a_idx, b_idx) } else { (b_idx, a_idx) }; + let Some(&a_idx) = group.item_to_idx.get(&a_key) else { + return false; + }; + let Some(&b_idx) = group.item_to_idx.get(&b_key) else { + return false; + }; + let (i, j) = if a_idx < b_idx { + (a_idx, b_idx) + } else { + (b_idx, a_idx) + }; group.voted_pairs.contains(&(i, j)) } -pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[ItemId]) -> ConnectivityStats { +pub fn compute_connectivity_stats( + group: &crate::reducer::GroupState, + pool: &[ItemId], +) -> ConnectivityStats { let n = pool.len(); let global_idxs: Vec> = pool @@ -185,7 +216,11 @@ pub fn compute_connectivity_stats(group: &crate::reducer::GroupState, pool: &[It ConnectivityStats { items: n, components: num_components, - comparisons_until_connected: if num_components > 0 { num_components - 1 } else { 0 }, + comparisons_until_connected: if num_components > 0 { + num_components - 1 + } else { + 0 + }, pairs_voted, pairs_possible: n * n.saturating_sub(1) / 2, } diff --git a/server/src/api/mod.rs b/server/src/api/mod.rs index 920e967b47852ea82fa61b84c457ae3582dd9800..acd35e86a32065515e782b9f7fc2af3712fdbe64 100644 --- a/server/src/api/mod.rs +++ b/server/src/api/mod.rs @@ -2,35 +2,24 @@ mod auth; mod helpers; mod rpc; mod stream; -mod validate; mod ui_html; +mod validate; pub(crate) mod write_actor; pub use auth::{ - get_join_invite, - get_pending_session, - get_whoami, - post_pending_session, - post_choose_username, - get_auth_login, - get_auth_callback, - get_auth_complete, - get_choose_username, - get_web_login, - get_logout, - optional_principal, - resolve_web_session, - session_cookie_header_value, - WebSession, - SLUG_SESSION_COOKIE, + get_auth_callback, get_auth_complete, get_auth_login, get_choose_username, get_join_invite, + get_logout, get_pending_session, get_web_login, get_whoami, optional_principal, + post_choose_username, post_pending_session, resolve_web_session, session_cookie_header_value, + WebSession, SLUG_SESSION_COOKIE, }; pub use helpers::{ api_error, compute_connectivity_stats, is_pair_voted, now_ms, paginate_rankings, - parse_parent_specs, pick_random_distinct_item_pair, resolve_item, sha256_hex, vote_touches_path, + parse_parent_specs, pick_random_distinct_item_pair, public_url, resolve_item, sha256_hex, + vote_touches_path, }; -pub use rpc::handle_rpc_batch; +pub use rpc::{dispatch_rpc, handle_rpc_batch}; pub use stream::{get_html_stream, get_stream}; @@ -61,7 +50,8 @@ mod tests { fn validate_ingest_document_parse_error() { let reduced = ReducerState::default(); let text = "~/t/a { unclosed "; - let err = validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap_err(); + let err = + validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap_err(); assert_eq!(err.0, StatusCode::BAD_REQUEST); assert_eq!(err.1, "parse error"); } @@ -82,7 +72,8 @@ mod tests { fn validate_ingest_document_rejects_vote_on_undefined_item() { let reduced = ReducerState::default(); let text = "~/t/a {x}\n{why}\n~/t/b 1:1 ~/t/missing\n"; - let err = validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap_err(); + let err = + validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap_err(); assert_eq!(err.0, StatusCode::BAD_REQUEST); assert!(err.1.contains("undefined item")); } @@ -105,7 +96,8 @@ mod tests { fn validate_ingest_document_rejects_item_without_body() { let reduced = ReducerState::default(); let text = "~/t/a\n"; - let err = validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap_err(); + let err = + validate_ingest_document(&reduced, text, &crate::reducer::ScopeId::Public).unwrap_err(); assert_eq!(err.0, StatusCode::BAD_REQUEST); assert!(err.1.contains("missing body")); } diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index dc73655060a494ab4b0b0293c6977ee8f9aa1329..967500003087e0ebf5577fccac78060b92193aba 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -1,16 +1,11 @@ use std::collections::HashSet; use std::sync::OnceLock; -use axum::{ - extract::State, - http::HeaderMap, - response::IntoResponse, - Json, -}; -use tokio::sync::oneshot; +use axum::{extract::State, http::HeaderMap, response::IntoResponse, Json}; use rand::seq::SliceRandom; use slug_types::paths::{ForumThreadUrl, GardenItemUrl, TildeOntologyPath}; use slug_types::*; +use tokio::sync::oneshot; use crate::{ canonical_path::{canonicalize_item, canonicalize_tag}, @@ -98,12 +93,13 @@ fn rpc_feed( .enumerate() .rev() .filter(|(index, id)| { - reduced.ingests_by_id.get(id.as_str()).is_some_and(|ing| { - match requested_since { + reduced + .ingests_by_id + .get(id.as_str()) + .is_some_and(|ing| match requested_since { Some(cutoff) => ing.ts > cutoff, None => implicit_anchor_index.is_none_or(|anchor| *index > anchor), - } - }) + }) }) .map(|(_, id)| id.as_str()) .filter(|id| { @@ -122,11 +118,8 @@ fn rpc_feed( .filter_map(|id| reduced.ingests_by_id.get(id)) .map(|ing| { let scope = scope_from_room_wire(&ing.room_id); - let thread_post_index = reduced.try_thread_post_index_chronological( - &scope, - &ing.thread_tag, - &ing.id, - ); + let thread_post_index = + reduced.try_thread_post_index_chronological(&scope, &ing.thread_tag, &ing.id); FeedPost { ts: ing.ts, id: ing.id.clone(), @@ -146,7 +139,10 @@ fn rpc_feed( } } -fn principal_from_optional_bearer(headers: &HeaderMap, reduced: &ReducerState) -> Result, RpcErr> { +fn principal_from_optional_bearer( + headers: &HeaderMap, + reduced: &ReducerState, +) -> Result, RpcErr> { if headers.contains_key(axum::http::header::AUTHORIZATION) { verify_bearer_principal(headers, reduced) .map(Some) @@ -162,7 +158,11 @@ fn principal_from_optional_bearer(headers: &HeaderMap, reduced: &ReducerState) - /// explicit View capability. Unknown and unauthorized private rooms are both returned as /// "not found" /// to avoid resource-enumeration leaks. -fn authorize_room_read(reduced: &ReducerState, headers: &HeaderMap, room: &str) -> Result, RpcErr> { +fn authorize_room_read( + reduced: &ReducerState, + headers: &HeaderMap, + room: &str, +) -> Result, RpcErr> { let scope = scope_from_room_wire(room); let ScopeId::Room(room_id) = scope else { return Ok(None); @@ -194,7 +194,9 @@ fn gen_invite_token() -> String { use rand::Rng; const ALPHABET: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; let mut rng = rand::thread_rng(); - let tail: String = (0..16).map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char).collect(); + let tail: String = (0..16) + .map(|_| ALPHABET[rng.gen_range(0..ALPHABET.len())] as char) + .collect(); format!("inv_{tail}") } @@ -297,8 +299,7 @@ pub async fn rpc_post_redact( }) .await .map_err(|_| ("writer unavailable".into(), None))?; - rx.await - .map_err(|_| ("writer dropped".into(), None))? + rx.await.map_err(|_| ("writer dropped".into(), None))? } pub async fn rpc_room_delete( @@ -317,8 +318,7 @@ pub async fn rpc_room_delete( }) .await .map_err(|_| ("writer unavailable".into(), None))?; - rx.await - .map_err(|_| ("writer dropped".into(), None))? + rx.await.map_err(|_| ("writer dropped".into(), None))? } pub async fn rpc_thread_graduate( @@ -339,8 +339,7 @@ pub async fn rpc_thread_graduate( }) .await .map_err(|_| ("writer unavailable".into(), None))?; - rx.await - .map_err(|_| ("writer dropped".into(), None))? + rx.await.map_err(|_| ("writer dropped".into(), None))? } async fn rpc_post( @@ -367,8 +366,7 @@ async fn rpc_post( }) .await .map_err(|_| ("writer unavailable".into(), None))?; - rx.await - .map_err(|_| ("writer dropped".into(), None))? + rx.await.map_err(|_| ("writer dropped".into(), None))? } /// Post forum content using a raw bearer token (CLI `Authorization` header or browser session cookie). @@ -451,8 +449,12 @@ async fn rpc_check( for s in &v.doc.statements { if let dsl::Stmt::Vote { item1, item2, .. } = s { if let (Ok(a), Ok(b)) = (resolve_item(item1), resolve_item(item2)) { - if let Some(p) = a.parent() { parents.insert(p); } - if let Some(p) = b.parent() { parents.insert(p); } + if let Some(p) = a.parent() { + parents.insert(p); + } + if let Some(p) = b.parent() { + parents.insert(p); + } } } } @@ -504,7 +506,9 @@ async fn rpc_check( ] } else { vec![ - format!("npx slugsocial private {room_key} forum post --delegate "), + format!( + "npx slugsocial private {room_key} forum post --delegate " + ), format!("npx slugsocial private {room_key} forum list"), ForumThreadUrl::from_room_tag(&room_key, &thread_id).into_inner(), ] @@ -557,7 +561,11 @@ fn rpc_forum_thread_detail( if let Some(pid) = post_id { let thread_ids = reduced.ingests_by_scope_thread.get(&key); let index = thread_ids.and_then(|ids| { - ids.iter().rev().enumerate().find(|(_, id)| *id == pid).map(|(i, _)| i) + ids.iter() + .rev() + .enumerate() + .find(|(_, id)| *id == pid) + .map(|(i, _)| i) }); return match index.and_then(|idx| reduced.ingests_by_id.get(pid).map(|ing| (idx, ing))) { None => Err(("post not found".into(), None)), @@ -576,7 +584,11 @@ fn rpc_forum_thread_detail( ts: ing.ts, actor: ing.principal.clone(), delegate: ing.delegate.clone(), - body: if redacted { String::new() } else { ing.raw.clone() }, + body: if redacted { + String::new() + } else { + ing.raw.clone() + }, truncated: false, redacted, redacted_at_ts, @@ -600,7 +612,9 @@ fn rpc_forum_thread_detail( .filter_map(|(idx, id)| reduced.ingests_by_id.get(&id).map(|ing| (idx, ing.clone()))) .filter(|(_, ing)| since.is_none_or(|s| ing.ts >= s)) .filter(|(_, ing)| before.is_none_or(|b| ing.ts < b)) - .filter(|(_, ing)| actor_prefix.is_empty() || ing.principal.to_lowercase().starts_with(actor_prefix)) + .filter(|(_, ing)| { + actor_prefix.is_empty() || ing.principal.to_lowercase().starts_with(actor_prefix) + }) .collect(); let total = filtered.len(); @@ -672,21 +686,34 @@ fn text_contains_any(text: &str, words: &[String]) -> usize { fn snippet_around(text: &str, words: &[String], max_len: usize) -> String { let lower = text.to_lowercase(); - let first_pos = words.iter().filter_map(|w| lower.find(w.as_str())).min().unwrap_or(0); + let first_pos = words + .iter() + .filter_map(|w| lower.find(w.as_str())) + .min() + .unwrap_or(0); let start = first_pos.saturating_sub(max_len / 3); let start = if start > 0 { let mut i = start; - while i < text.len() && !text.is_char_boundary(i) { i += 1; } + while i < text.len() && !text.is_char_boundary(i) { + i += 1; + } text[i..].find(' ').map(|j| i + j + 1).unwrap_or(i) } else { 0 }; let mut end = (start + max_len).min(text.len()); - while end < text.len() && !text.is_char_boundary(end) { end += 1; } + while end < text.len() && !text.is_char_boundary(end) { + end += 1; + } text[start..end].to_string() } -fn rpc_search(reduced: &ReducerState, q: &str, limit: usize, principal: Option<&str>) -> SearchResponse { +fn rpc_search( + reduced: &ReducerState, + q: &str, + limit: usize, + principal: Option<&str>, +) -> SearchResponse { let words = tokenize_query(q); if words.is_empty() { return SearchResponse { @@ -699,62 +726,88 @@ fn rpc_search(reduced: &ReducerState, q: &str, limit: usize, principal: Option<& let mut scored_items: Vec<(u32, SearchItemHit)> = Vec::new(); for item in &content.items { let mut score: u32 = 0; - if text_contains_all(item.as_str(), &words) { score += 10; } - else if text_contains_any(item.as_str(), &words) > 0 { score += 5; } + if text_contains_all(item.as_str(), &words) { + score += 10; + } else if text_contains_any(item.as_str(), &words) > 0 { + score += 5; + } if let Some(body) = content.item_bodies.get(item) { - if text_contains_all(body, &words) { score += 6; } - else { + if text_contains_all(body, &words) { + score += 6; + } else { let any = text_contains_any(body, &words); - if any > 0 { score += any as u32; } + if any > 0 { + score += any as u32; + } } } if score > 0 { - scored_items.push((score, SearchItemHit { - path: GardenItemUrl::from_storage_str(item.as_str(), "public"), - body: content.item_bodies.get(item).map(|b| snippet_around(b, &words, 120)), - })); + scored_items.push(( + score, + SearchItemHit { + path: GardenItemUrl::from_storage_str(item.as_str(), "public"), + body: content + .item_bodies + .get(item) + .map(|b| snippet_around(b, &words, 120)), + }, + )); } } let mut scored_threads: Vec<(u32, i64, SearchThreadHit)> = Vec::new(); for ((scope, tag), ts) in &reduced.forum_threads { - if scope != &ScopeId::Public { continue; } + if scope != &ScopeId::Public { + continue; + } let mut score: u32 = 0; - if text_contains_all(tag, &words) { score += 8; } - else if text_contains_any(tag, &words) > 0 { score += 4; } + if text_contains_all(tag, &words) { + score += 8; + } else if text_contains_any(tag, &words) > 0 { + score += 4; + } if score > 0 { let post_count = reduced .ingests_by_scope_thread .get(&(ScopeId::Public, tag.clone())) .map(|q| q.len()) .unwrap_or(0); - scored_threads.push((score, ts.last_activity_ts, SearchThreadHit { - tag: format!("#{tag}"), - post_count, - last_activity: ts.last_activity_ts, - })); + scored_threads.push(( + score, + ts.last_activity_ts, + SearchThreadHit { + tag: format!("#{tag}"), + post_count, + last_activity: ts.last_activity_ts, + }, + )); } } let mut scored_posts: Vec<(u32, i64, SearchPostHit)> = Vec::new(); for (id, ingest) in &reduced.ingests_by_id { let mut score: u32 = 0; - if text_contains_all(&ingest.raw, &words) { score += 4; } - else { + if text_contains_all(&ingest.raw, &words) { + score += 4; + } else { let any = text_contains_any(&ingest.raw, &words); - if any > 0 { score += any as u32; } + if any > 0 { + score += any as u32; + } } if score > 0 { - let Some((scope, tag)) = reduced - .ingests_by_scope_thread - .iter() - .find_map(|((scope, tag), ids)| { - if ids.contains(id) { - Some((scope.clone(), tag.clone())) - } else { - None - } - }) else { - continue; - }; + let Some((scope, tag)) = + reduced + .ingests_by_scope_thread + .iter() + .find_map(|((scope, tag), ids)| { + if ids.contains(id) { + Some((scope.clone(), tag.clone())) + } else { + None + } + }) + else { + continue; + }; if !can_view_scope(reduced, &scope, principal) { continue; } @@ -762,12 +815,17 @@ fn rpc_search(reduced: &ReducerState, q: &str, limit: usize, principal: Option<& ScopeId::Public => format!("#{tag}"), ScopeId::Room(rid) => format!("{rid}/#{tag}"), }; - scored_posts.push((score, ingest.ts, SearchPostHit { - thread, - actor: ingest.principal.clone(), - snippet: snippet_around(&ingest.raw, &words, 160), - ts: ingest.ts, - })); + scored_posts.push(( + score, + ingest.ts, + SearchPostHit { + id: ingest.id.clone(), + thread, + actor: ingest.principal.clone(), + snippet: snippet_around(&ingest.raw, &words, 160), + ts: ingest.ts, + }, + )); } } scored_items.sort_by(|a, b| b.0.cmp(&a.0)); @@ -783,7 +841,11 @@ fn rpc_search(reduced: &ReducerState, q: &str, limit: usize, principal: Option<& } } -async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Result { +async fn rpc_get_pair( + state: &AppState, + room: String, + parent_path: String, +) -> Result { let scope = scope_from_room_wire(&room); let reduced_arc = state.reduced.clone(); let pool: Vec = { @@ -853,7 +915,12 @@ async fn rpc_get_pair(state: &AppState, room: String, parent_path: String) -> Re } if pick.is_none() { for _ in 0..64 { - let (Some(a), Some(b)) = (pool.choose(&mut rng).cloned(), pool.choose(&mut rng).cloned()) else { break; }; + let (Some(a), Some(b)) = ( + pool.choose(&mut rng).cloned(), + pool.choose(&mut rng).cloned(), + ) else { + break; + }; if a != b && !is_pair_voted(group, a.as_str(), b.as_str()) { pick = Some((a, b)); break; @@ -902,33 +969,51 @@ pub async fn handle_rpc_batch( let mut results = Vec::with_capacity(commands.len()); for cmd in commands { - let line = match cmd { - RpcCommand::Post { - room, - thread_tag, - delegate, - text, - return_rank_diff, - } => match rpc_post(&state, &headers, room, thread_tag, delegate, text, return_rank_diff).await { - Ok(r) => line_ok(r), - Err((e, h)) => line_err(e, h), - }, - RpcCommand::Check { room, text } => match rpc_check(&state, &headers, room, text).await { - Ok(r) => line_ok(r), - Err((e, h)) => line_err(e, h), - }, - RpcCommand::GetGardenRank { - room, - parent_path, - depth, - offset, - limit, - percent, - } => { - let reduced = state.reduced.read().await; - if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { - line_err(e, h) - } else { + results.push(dispatch_rpc(&state, &headers, cmd).await); + } + + Json(RpcBatchResponse { results }).into_response() +} + +/// Run one RPC command. Shared by `POST /api/v0/rpc` and the ChatGPT MCP tools. +pub async fn dispatch_rpc(state: &AppState, headers: &HeaderMap, cmd: RpcCommand) -> RpcLine { + match cmd { + RpcCommand::Post { + room, + thread_tag, + delegate, + text, + return_rank_diff, + } => match rpc_post( + &state, + &headers, + room, + thread_tag, + delegate, + text, + return_rank_diff, + ) + .await + { + Ok(r) => line_ok(r), + Err((e, h)) => line_err(e, h), + }, + RpcCommand::Check { room, text } => match rpc_check(&state, &headers, room, text).await { + Ok(r) => line_ok(r), + Err((e, h)) => line_err(e, h), + }, + RpcCommand::GetGardenRank { + room, + parent_path, + depth, + offset, + limit, + percent, + } => { + let reduced = state.reduced.read().await; + if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { + line_err(e, h) + } else { let content = content_for_room(&reduced, &room); let popt = if parent_path.trim().is_empty() { None @@ -947,24 +1032,28 @@ pub async fn handle_rpc_batch( Ok(r) => line_ok(RpcResult::GardenRank(r)), Err((e, h)) => line_err(e, h), } - } } - RpcCommand::GetGardenItem { - room, - item_path, - full, - } => { - let reduced = state.reduced.read().await; - if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { - line_err(e, h) - } else { + } + RpcCommand::GetGardenItem { + room, + item_path, + full, + } => { + let reduced = state.reduced.read().await; + if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { + line_err(e, h) + } else { let content = content_for_room(&reduced, &room); let item_str = canonicalize_item(&item_path); - let item = ItemId::parse(&item_str).unwrap_or_else(|| ItemId::opaque(item_str.clone())); + let item = + ItemId::parse(&item_str).unwrap_or_else(|| ItemId::opaque(item_str.clone())); if !content.items.contains(&item) { line_err( "item not found", - Some(format!("{} does not exist", GardenItemUrl::from_storage_str(&item_str, &room))), + Some(format!( + "{} does not exist", + GardenItemUrl::from_storage_str(&item_str, &room) + )), ) } else { let want_full = full.unwrap_or(false); @@ -997,25 +1086,26 @@ pub async fn handle_rpc_batch( threads, })) } - } } - RpcCommand::GetForumThread { - room, - thread_tag, - offset, - limit, - since, - before, - actor, - post_id, - } => { - let reduced = state.reduced.read().await; - if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { - line_err(e, h) - } else { + } + RpcCommand::GetForumThread { + room, + thread_tag, + offset, + limit, + since, + before, + actor, + post_id, + } => { + let reduced = state.reduced.read().await; + if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { + line_err(e, h) + } else { let actor_prefix = match actor.as_deref().map(str::trim) { None | Some("") => Ok(String::new()), - Some(s) => parse_username(s).map_err(|msg| ("invalid actor filter".to_string(), Some(msg))), + Some(s) => parse_username(s) + .map_err(|msg| ("invalid actor filter".to_string(), Some(msg))), }; match actor_prefix { Err((e, h)) => line_err(e, h), @@ -1038,282 +1128,301 @@ pub async fn handle_rpc_batch( } } } - } } - RpcCommand::ListForumThreads { room } => { - let reduced = state.reduced.read().await; - if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { - line_err(e, h) - } else { - line_ok(RpcResult::ForumThreads(rpc_list_forum_threads(&reduced, &room))) - } + } + RpcCommand::ListForumThreads { room } => { + let reduced = state.reduced.read().await; + if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { + line_err(e, h) + } else { + line_ok(RpcResult::ForumThreads(rpc_list_forum_threads( + &reduced, &room, + ))) } - RpcCommand::RoomCreate { slug } => { - match parse_bearer(&headers) { - Err((_, m)) => line_err(m, None), - Ok(bearer) => { - let (tx, rx) = oneshot::channel(); - match state - .write_tx - .send(WriteCmd::RoomCreate { - slug, - bearer, - reply: tx, - }) - .await - { - Err(_) => line_err("writer unavailable", None), - Ok(()) => match rx.await { - Err(_) => line_err("writer dropped", None), - Ok(Ok(r)) => line_ok(r), - Ok(Err((msg, hint))) => line_err(msg, hint), - }, - } - } + } + RpcCommand::RoomCreate { slug } => match parse_bearer(&headers) { + Err((_, m)) => line_err(m, None), + Ok(bearer) => { + let (tx, rx) = oneshot::channel(); + match state + .write_tx + .send(WriteCmd::RoomCreate { + slug, + bearer, + reply: tx, + }) + .await + { + Err(_) => line_err("writer unavailable", None), + Ok(()) => match rx.await { + Err(_) => line_err("writer dropped", None), + Ok(Ok(r)) => line_ok(r), + Ok(Err((msg, hint))) => line_err(msg, hint), + }, } } - RpcCommand::RoomGrant { - room, - username, - capabilities, - } => { - match parse_bearer(&headers) { - Err((_, m)) => line_err(m, None), - Ok(bearer) => { - let (tx, rx) = oneshot::channel(); - match state - .write_tx - .send(WriteCmd::Grant { - room, - username, - capabilities, - bearer, - reply: tx, - }) - .await - { - Err(_) => line_err("writer unavailable", None), - Ok(()) => match rx.await { - Err(_) => line_err("writer dropped", None), - Ok(Ok(r)) => line_ok(r), - Ok(Err((msg, hint))) => line_err(msg, hint), - }, - } - } + }, + RpcCommand::RoomGrant { + room, + username, + capabilities, + } => match parse_bearer(&headers) { + Err((_, m)) => line_err(m, None), + Ok(bearer) => { + let (tx, rx) = oneshot::channel(); + match state + .write_tx + .send(WriteCmd::Grant { + room, + username, + capabilities, + bearer, + reply: tx, + }) + .await + { + Err(_) => line_err("writer unavailable", None), + Ok(()) => match rx.await { + Err(_) => line_err("writer dropped", None), + Ok(Ok(r)) => line_ok(r), + Ok(Err((msg, hint))) => line_err(msg, hint), + }, } } - // Invite links are stored in AppState only (not JSONL); they do not survive restart. - RpcCommand::RoomMintInvite { - room, - capabilities, - max_uses, - } => { - let principal = { - let reduced = state.reduced.read().await; - verify_bearer_principal(&headers, &reduced) - }; - match principal { - Err((_, m)) => line_err(m, None), - Ok(principal) => { - let can_manage = { - let reduced = state.reduced.read().await; - reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) - }; - if !can_manage { - line_err("requires Manage capability", None) - } else if capabilities.is_empty() { - line_err("capabilities must not be empty", None) - } else { - match capabilities - .iter() - .map(|c| parse_capability(c.trim())) - .collect::, String>>() - { - Err(msg) => line_err(msg, None), - Ok(caps) => { - let max_uses = max_uses.clamp(1, 100_000); - let now = now_ms(); - let expires_at_ms = now + INVITE_TTL_MS; - let token = loop { - let t = gen_invite_token(); - let taken = { - let invites = state.invites.read().await; - invites.contains_key(&t) - }; - if !taken { - break t; - } - }; - let inv = InviteState { - room_id: room.clone(), - capabilities: caps, - expires_at_ms, - max_uses, - current_uses: 0, - inviter: principal, + }, + // Invite links are stored in AppState only (not JSONL); they do not survive restart. + RpcCommand::RoomMintInvite { + room, + capabilities, + max_uses, + } => { + let principal = { + let reduced = state.reduced.read().await; + verify_bearer_principal(&headers, &reduced) + }; + match principal { + Err((_, m)) => line_err(m, None), + Ok(principal) => { + let can_manage = { + let reduced = state.reduced.read().await; + reduced.user_has_cap(&room, &principal, ThreadCapability::Manage) + }; + if !can_manage { + line_err("requires Manage capability", None) + } else if capabilities.is_empty() { + line_err("capabilities must not be empty", None) + } else { + match capabilities + .iter() + .map(|c| parse_capability(c.trim())) + .collect::, String>>() + { + Err(msg) => line_err(msg, None), + Ok(caps) => { + let max_uses = max_uses.clamp(1, 100_000); + let now = now_ms(); + let expires_at_ms = now + INVITE_TTL_MS; + let token = loop { + let t = gen_invite_token(); + let taken = { + let invites = state.invites.read().await; + invites.contains_key(&t) }; - state.invites.write().await.insert(token.clone(), inv); - let public_url = std::env::var("SLUG_PUBLIC_URL") - .unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); - let invite_url = format!("{public_url}/join/{token}"); - line_ok(RpcResult::RoomInviteMinted { - invite_url, - expires_at_ms: Some(expires_at_ms), - max_uses, - }) - } + if !taken { + break t; + } + }; + let inv = InviteState { + room_id: room.clone(), + capabilities: caps, + expires_at_ms, + max_uses, + current_uses: 0, + inviter: principal, + }; + state.invites.write().await.insert(token.clone(), inv); + let public_url = std::env::var("SLUG_PUBLIC_URL") + .unwrap_or_else(|_| "http://127.0.0.1:8080".to_string()); + let invite_url = format!("{public_url}/join/{token}"); + line_ok(RpcResult::RoomInviteMinted { + invite_url, + expires_at_ms: Some(expires_at_ms), + max_uses, + }) } } } } } - RpcCommand::RoomAudit { room } => { - let principal = { + } + RpcCommand::RoomAudit { room } => { + let principal = { + let reduced = state.reduced.read().await; + verify_bearer_principal(&headers, &reduced) + }; + match principal { + Err((_, m)) => line_err(m, None), + Ok(principal) => { let reduced = state.reduced.read().await; - verify_bearer_principal(&headers, &reduced) - }; - match principal { - Err((_, m)) => line_err(m, None), - Ok(principal) => { - let reduced = state.reduced.read().await; - if room == "public" { - line_err("audit is only available for private rooms", None) - } else if !reduced.rooms.contains(&room) { - line_err("unknown room", None) + if room == "public" { + line_err("audit is only available for private rooms", None) + } else if !reduced.rooms.contains(&room) { + line_err("unknown room", None) + } else { + let can_audit = + reduced.user_has_cap(&room, &principal, ThreadCapability::View) + || reduced.user_has_cap( + &room, + &principal, + ThreadCapability::Manage, + ); + if !can_audit { + line_err("requires View or Manage capability", None) } else { - let can_audit = reduced.user_has_cap(&room, &principal, ThreadCapability::View) - || reduced.user_has_cap(&room, &principal, ThreadCapability::Manage); - if !can_audit { - line_err("requires View or Manage capability", None) - } else { - let grants: Vec = reduced - .grants - .get(&room) - .map(|m| { - let mut v: Vec = m - .iter() - .map(|(username, caps)| { - let mut c: Vec = - caps.iter().copied().map(capability_wire).collect(); - c.sort(); - RoomAuditEntry { - username: username.clone(), - capabilities: c, - } - }) - .collect(); - v.sort_by(|a, b| a.username.cmp(&b.username)); - v - }) - .unwrap_or_default(); - line_ok(RpcResult::RoomAudit(RoomAuditResponse { room, grants })) - } + let grants: Vec = reduced + .grants + .get(&room) + .map(|m| { + let mut v: Vec = m + .iter() + .map(|(username, caps)| { + let mut c: Vec = + caps.iter().copied().map(capability_wire).collect(); + c.sort(); + RoomAuditEntry { + username: username.clone(), + capabilities: c, + } + }) + .collect(); + v.sort_by(|a, b| a.username.cmp(&b.username)); + v + }) + .unwrap_or_default(); + line_ok(RpcResult::RoomAudit(RoomAuditResponse { room, grants })) } } } } - RpcCommand::RoomList => { - let principal = { + } + RpcCommand::RoomList => { + let principal = { + let reduced = state.reduced.read().await; + verify_bearer_principal(&headers, &reduced) + }; + match principal { + Err((_, m)) => line_err(m, None), + Ok(principal) => { let reduced = state.reduced.read().await; - verify_bearer_principal(&headers, &reduced) - }; - match principal { - Err((_, m)) => line_err(m, None), - Ok(principal) => { - let reduced = state.reduced.read().await; - let mut rooms: Vec<(i64, String)> = reduced - .grants - .iter() - .filter(|(room, members)| { - reduced.rooms.contains(*room) && members.contains_key(&principal) - }) - .map(|(room, _)| (reduced.room_last_activity_ts(room), room.clone())) - .collect(); - // Newest activity first; stable tie-break on room id. - rooms.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); - let rooms: Vec = rooms.into_iter().map(|(_, id)| id).collect(); - line_ok(RpcResult::RoomList(RoomListResponse { rooms })) - } + let mut rooms: Vec<(i64, String)> = reduced + .grants + .iter() + .filter(|(room, members)| { + reduced.rooms.contains(*room) && members.contains_key(&principal) + }) + .map(|(room, _)| (reduced.room_last_activity_ts(room), room.clone())) + .collect(); + // Newest activity first; stable tie-break on room id. + rooms.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + let rooms: Vec = rooms.into_iter().map(|(_, id)| id).collect(); + line_ok(RpcResult::RoomList(RoomListResponse { rooms })) } } - RpcCommand::RoomDelete { room } => match rpc_room_delete(&state, &headers, room).await { + } + RpcCommand::RoomDelete { room } => match rpc_room_delete(&state, &headers, room).await { + Ok(r) => line_ok(r), + Err((e, h)) => line_err(e, h), + }, + RpcCommand::ThreadGraduate { room, thread_tag } => { + match rpc_thread_graduate(&state, &headers, room, thread_tag).await { Ok(r) => line_ok(r), Err((e, h)) => line_err(e, h), - }, - RpcCommand::ThreadGraduate { room, thread_tag } => { - match rpc_thread_graduate(&state, &headers, room, thread_tag).await { - Ok(r) => line_ok(r), - Err((e, h)) => line_err(e, h), - } } - RpcCommand::RoomRevoke { - room, - username, - capability, - } => { - match parse_bearer(&headers) { - Err((_, m)) => line_err(m, None), - Ok(bearer) => { - let (tx, rx) = oneshot::channel(); - match state - .write_tx - .send(WriteCmd::Revoke { - room, - username, - capability, - bearer, - reply: tx, - }) - .await - { - Err(_) => line_err("writer unavailable", None), - Ok(()) => match rx.await { - Err(_) => line_err("writer dropped", None), - Ok(Ok(r)) => line_ok(r), - Ok(Err((msg, hint))) => line_err(msg, hint), - }, - } - } - } - }, - RpcCommand::GetGlobalRank { - room, - limit, - offset, - percent, - } => { + } + RpcCommand::RoomRevoke { + room, + username, + capability, + } => match parse_bearer(&headers) { + Err((_, m)) => line_err(m, None), + Ok(bearer) => { + let (tx, rx) = oneshot::channel(); + match state + .write_tx + .send(WriteCmd::Revoke { + room, + username, + capability, + bearer, + reply: tx, + }) + .await { - let reduced = state.reduced.read().await; - if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { - results.push(line_err(e, h)); - continue; - } + Err(_) => line_err("writer unavailable", None), + Ok(()) => match rx.await { + Err(_) => line_err("writer dropped", None), + Ok(Ok(r)) => line_ok(r), + Ok(Err((msg, hint))) => line_err(msg, hint), + }, } + } + }, + RpcCommand::GetGlobalRank { + room, + limit, + offset, + percent, + } => { + let reduced = state.reduced.read().await; + if let Err((e, h)) = authorize_room_read(&reduced, headers, &room) { + line_err(e, h) + } else { const DEFAULT_LIMIT: usize = 50; const MAX_LIMIT: usize = 500; let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); let offset = offset.unwrap_or(0); let want_percent = percent.unwrap_or(false); - let reduced = state.reduced.read().await; let content = content_for_room(&reduced, &room); let all_items: Vec = content.items.iter().cloned().collect(); let rankings = crate::scope_rank::build_rankings_for_item_set(content, &all_items); - let ranked_total: usize = rankings.component_rankings.iter().map(|component| component.ranked.len()).sum(); + let ranked_total: usize = rankings + .component_rankings + .iter() + .map(|component| component.ranked.len()) + .sum(); let unranked_total = rankings.unranked_items.len(); - let components: Vec = rankings.component_rankings.into_iter().map(|component| { - let top = component.ranked.first().map(|r| r.score).unwrap_or(1.0).max(1e-12); - RankComponent { - pairs: component.pairs, - ranking: component.ranked.into_iter().map(|ranked| RankRow { - item: GardenItemUrl::from_stored(&ranked.item, &room), - score: ranked.score, - percent: want_percent.then(|| (ranked.score / top * 100.0).clamp(0.0, 100.0)), - }).collect(), - } - }).collect(); - let unranked: Vec = rankings.unranked_items.into_iter() - .map(|item| GardenItemUrl::from_stored(&item, &room)).collect(); - let (components, unranked_items) = paginate_rankings(components, unranked, offset, Some(limit)); + let components: Vec = rankings + .component_rankings + .into_iter() + .map(|component| { + let top = component + .ranked + .first() + .map(|r| r.score) + .unwrap_or(1.0) + .max(1e-12); + RankComponent { + pairs: component.pairs, + ranking: component + .ranked + .into_iter() + .map(|ranked| RankRow { + item: GardenItemUrl::from_stored(&ranked.item, &room), + score: ranked.score, + percent: want_percent + .then(|| (ranked.score / top * 100.0).clamp(0.0, 100.0)), + }) + .collect(), + } + }) + .collect(); + let unranked: Vec = rankings + .unranked_items + .into_iter() + .map(|item| GardenItemUrl::from_stored(&item, &room)) + .collect(); + let (components, unranked_items) = + paginate_rankings(components, unranked, offset, Some(limit)); line_ok(RpcResult::GlobalRank(GlobalRankResponse { ranked_total, @@ -1324,35 +1433,40 @@ pub async fn handle_rpc_batch( unranked_items, })) } - RpcCommand::GetPair { room, parent_path } => { - let reduced = state.reduced.read().await; - if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { - line_err(e, h) - } else { - drop(reduced); - match rpc_get_pair(&state, room, parent_path).await { - Ok(r) => line_ok(r), - Err((e, h)) => line_err(e, h), - } + } + RpcCommand::GetPair { room, parent_path } => { + let reduced = state.reduced.read().await; + if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { + line_err(e, h) + } else { + drop(reduced); + match rpc_get_pair(&state, room, parent_path).await { + Ok(r) => line_ok(r), + Err((e, h)) => line_err(e, h), } } - RpcCommand::GetMatchup { - room, - item_path, - limit, - } => { - let reduced = state.reduced.read().await; - if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { - line_err(e, h) - } else { + } + RpcCommand::GetMatchup { + room, + item_path, + limit, + } => { + let reduced = state.reduced.read().await; + if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { + line_err(e, h) + } else { let content = content_for_room(&reduced, &room); let item_str = canonicalize_item(&item_path); - let item = ItemId::parse(&item_str).unwrap_or_else(|| ItemId::opaque(item_str.clone())); + let item = + ItemId::parse(&item_str).unwrap_or_else(|| ItemId::opaque(item_str.clone())); let limit = limit.unwrap_or(50).clamp(1, 200); if !content.items.contains(&item) { line_err( "item not found", - Some(format!("{} does not exist", GardenItemUrl::from_storage_str(&item_str, &room))), + Some(format!( + "{} does not exist", + GardenItemUrl::from_storage_str(&item_str, &room) + )), ) } else { let votes: Vec = content @@ -1366,7 +1480,8 @@ pub async fn handle_rpc_batch( a: GardenItemUrl::from_stored(&v.a, &room), b: GardenItemUrl::from_stored(&v.b, &room), ratio: { - let (l, r) = crate::dsl::reduce_ratio(v.ratio_left, v.ratio_right); + let (l, r) = + crate::dsl::reduce_ratio(v.ratio_left, v.ratio_right); format!("{l}:{r}") }, actor: Some(v.principal.clone()), @@ -1381,75 +1496,97 @@ pub async fn handle_rpc_batch( votes, })) } - } - }, - RpcCommand::GetRankHistory { room, item_path } => { - let reduced = state.reduced.read().await; - if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { - line_err(e, h) - } else { + } + } + RpcCommand::GetRankHistory { room, item_path } => { + let reduced = state.reduced.read().await; + if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { + line_err(e, h) + } else { let content = content_for_room(&reduced, &room); let scope = scope_from_room_wire(&room); let item_str = canonicalize_item(&item_path); - let item = ItemId::parse(&item_str).unwrap_or_else(|| ItemId::opaque(item_str.clone())); + let item = + ItemId::parse(&item_str).unwrap_or_else(|| ItemId::opaque(item_str.clone())); let entries = content.rank_history.get(&item).cloned().unwrap_or_default(); - let history: Vec = entries.iter().map(|e| { - let caused_by: Vec = reduced.ingests_by_id.get(&e.post_id) - .and_then(|ing| crate::dsl::parse_full(&ing.raw).ok()) - .map(|doc| { - doc.statements.into_iter().filter_map(|s| { - if let crate::dsl::Stmt::Vote { item1, item2, ratio_left, ratio_right, explanation } = s { - let a = canonicalize_item(&item1); - let b = canonicalize_item(&item2); - if a == item_str || b == item_str { - Some(VoteRow { - ts: e.ts, - a: GardenItemUrl::from_storage_str(&a, &room), - b: GardenItemUrl::from_storage_str(&b, &room), - ratio: { - let (l, r) = crate::dsl::reduce_ratio(ratio_left, ratio_right); - format!("{l}:{r}") - }, - actor: reduced.ingests_by_id.get(&e.post_id).map(|ing| ing.principal.clone()), - body: explanation, - thread: Some(format!("#{}", e.thread)), - }) - } else { - None - } - } else { - None - } - }).collect() - }) - .unwrap_or_default(); - let thread_post_index = - reduced.thread_post_index_chronological(&scope, &e.thread, &e.post_id); - RankHistoryRow { - ts: e.ts, - scope_rank: e.scope_rank, - scope_rank_delta: e.scope_rank_delta, - scope_total: e.scope_total, - global_rank: e.global_rank, - global_rank_delta: e.global_rank_delta, - global_total: e.global_total, - score: e.score, - thread: format!("#{}", e.thread), - thread_post_index, - caused_by, - } - }).collect(); + let history: Vec = entries + .iter() + .map(|e| { + let caused_by: Vec = reduced + .ingests_by_id + .get(&e.post_id) + .and_then(|ing| crate::dsl::parse_full(&ing.raw).ok()) + .map(|doc| { + doc.statements + .into_iter() + .filter_map(|s| { + if let crate::dsl::Stmt::Vote { + item1, + item2, + ratio_left, + ratio_right, + explanation, + } = s + { + let a = canonicalize_item(&item1); + let b = canonicalize_item(&item2); + if a == item_str || b == item_str { + Some(VoteRow { + ts: e.ts, + a: GardenItemUrl::from_storage_str(&a, &room), + b: GardenItemUrl::from_storage_str(&b, &room), + ratio: { + let (l, r) = crate::dsl::reduce_ratio( + ratio_left, + ratio_right, + ); + format!("{l}:{r}") + }, + actor: reduced + .ingests_by_id + .get(&e.post_id) + .map(|ing| ing.principal.clone()), + body: explanation, + thread: Some(format!("#{}", e.thread)), + }) + } else { + None + } + } else { + None + } + }) + .collect() + }) + .unwrap_or_default(); + let thread_post_index = + reduced.thread_post_index_chronological(&scope, &e.thread, &e.post_id); + RankHistoryRow { + ts: e.ts, + scope_rank: e.scope_rank, + scope_rank_delta: e.scope_rank_delta, + scope_total: e.scope_total, + global_rank: e.global_rank, + global_rank_delta: e.global_rank_delta, + global_total: e.global_total, + score: e.score, + thread: format!("#{}", e.thread), + thread_post_index, + caused_by, + } + }) + .collect(); line_ok(RpcResult::RankHistory(RankHistoryResponse { item: GardenItemUrl::from_storage_str(&item_str, &room), history, })) - } - }, - RpcCommand::GetLeaves { room } => { - let reduced = state.reduced.read().await; - if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { - line_err(e, h) - } else { + } + } + RpcCommand::GetLeaves { room } => { + let reduced = state.reduced.read().await; + if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { + line_err(e, h) + } else { let content = content_for_room(&reduced, &room); let parents: HashSet = content .item_children @@ -1464,50 +1601,58 @@ pub async fn handle_rpc_batch( .collect(); paths.sort(); line_ok(RpcResult::Leaves(LeavesResponse { paths })) - } - }, - RpcCommand::GetPaths { room } => { - let reduced = state.reduced.read().await; - if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { - line_err(e, h) - } else { + } + } + RpcCommand::GetPaths { room } => { + let reduced = state.reduced.read().await; + if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { + line_err(e, h) + } else { let content = content_for_room(&reduced, &room); let out: Vec = content .item_children .get(&ItemId::ontology_root()) .map(|roots| { - let mut v: Vec = roots.iter() + let mut v: Vec = roots + .iter() .map(|path| { - let children = content.item_children.get(path).map(|s| s.len()).unwrap_or(0); + let children = content + .item_children + .get(path) + .map(|s| s.len()) + .unwrap_or(0); PathSummary { path: TildeOntologyPath::from_stored(path), children, web: GardenItemUrl::from_stored(path, &room), } - }).collect(); + }) + .collect(); v.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str())); v }) .unwrap_or_default(); line_ok(RpcResult::Paths(PathsResponse { paths: out })) - } - }, - RpcCommand::GetRecentVotes { - room, - parent, - limit, - } => { - let reduced = state.reduced.read().await; - if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { - line_err(e, h) - } else { + } + } + RpcCommand::GetRecentVotes { + room, + parent, + limit, + } => { + let reduced = state.reduced.read().await; + if let Err((e, h)) = authorize_room_read(&reduced, &headers, &room) { + line_err(e, h) + } else { let content = content_for_room(&reduced, &room); let group = &content.ranking_group; let limit = limit.unwrap_or(25).clamp(1, 200); let iter = group.recent_votes.iter(); let iter: Box> = if let Some(p) = &parent { let parent_can = canonicalize_item(p); - Box::new(iter.filter(move |v| vote_touches_path(v.a.as_str(), v.b.as_str(), &parent_can))) + Box::new(iter.filter(move |v| { + vote_touches_path(v.a.as_str(), v.b.as_str(), &parent_can) + })) } else { Box::new(iter) }; @@ -1524,58 +1669,66 @@ pub async fn handle_rpc_batch( actor: Some(v.principal.clone()), body: v.body.clone(), thread: Some(v.thread_tag.clone()), - }).collect(); + }) + .collect(); line_ok(RpcResult::RecentVotes(RecentVotesResponse { votes: out })) - } - }, - RpcCommand::Search { query } => { - let reduced = state.reduced.read().await; - let limit = 50usize; - match principal_from_optional_bearer(&headers, &reduced) { - Ok(principal) => line_ok(RpcResult::Search(rpc_search(&reduced, &query, limit, principal.as_deref()))), - Err((e, h)) => line_err(e, h), - } - }, - RpcCommand::GetFeed { - delegate, - since, - limit, - } => { - const DEFAULT_LIMIT: usize = 50; - const MAX_LIMIT: usize = 200; - let reduced = state.reduced.read().await; - match verify_bearer_principal(&headers, &reduced) { - Err((_, m)) => line_err(m, None), - Ok(viewer) => { - let delegate_parsed: Option> = delegate - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(parse_agent); - match delegate_parsed { - Some(Err(msg)) => { - drop(reduced); - line_err("invalid delegate", Some(msg)) - } - Some(Ok(delegate_stored)) => { - let line = if reduced.agent_bindings.get(&delegate_stored) != Some(&viewer) { + } + } + RpcCommand::Search { query } => { + let reduced = state.reduced.read().await; + let limit = 50usize; + match principal_from_optional_bearer(&headers, &reduced) { + Ok(principal) => line_ok(RpcResult::Search(rpc_search( + &reduced, + &query, + limit, + principal.as_deref(), + ))), + Err((e, h)) => line_err(e, h), + } + } + RpcCommand::GetFeed { + delegate, + since, + limit, + } => { + const DEFAULT_LIMIT: usize = 50; + const MAX_LIMIT: usize = 200; + let reduced = state.reduced.read().await; + match verify_bearer_principal(&headers, &reduced) { + Err((_, m)) => line_err(m, None), + Ok(viewer) => { + let delegate_parsed: Option> = delegate + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(parse_agent); + match delegate_parsed { + Some(Err(msg)) => { + drop(reduced); + line_err("invalid delegate", Some(msg)) + } + Some(Ok(delegate_stored)) => { + let line = + if reduced.agent_bindings.get(&delegate_stored) != Some(&viewer) { line_err( "not your delegate", - Some("this delegate is not bound to your signed-in account".into()), + Some( + "this delegate is not bound to your signed-in account" + .into(), + ), ) } else { - let implicit_anchor = reduced - .ingests_ordered - .iter() - .enumerate() - .rev() - .find_map(|(index, id)| { - reduced.ingests_by_id.get(id).and_then(|ing| { - (ing.delegate.as_deref() - == Some(delegate_stored.as_str())) - .then_some((index, ing.ts)) - }) - }); + let implicit_anchor = + reduced.ingests_ordered.iter().enumerate().rev().find_map( + |(index, id)| { + reduced.ingests_by_id.get(id).and_then(|ing| { + (ing.delegate.as_deref() + == Some(delegate_stored.as_str())) + .then_some((index, ing.ts)) + }) + }, + ); let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); line_ok(RpcResult::Feed(rpc_feed( &reduced, @@ -1586,47 +1739,41 @@ pub async fn handle_rpc_batch( limit, ))) }; - drop(reduced); - line - } - None => { - // Session catch-up: last time *you* posted anything (delegate or not), so revisiting - // an old chat with only a token still gets a sane cutoff. - let implicit_anchor = reduced - .ingests_ordered - .iter() - .enumerate() - .rev() - .find_map(|(index, id)| { + drop(reduced); + line + } + None => { + // Session catch-up: last time *you* posted anything (delegate or not), so revisiting + // an old chat with only a token still gets a sane cutoff. + let implicit_anchor = + reduced.ingests_ordered.iter().enumerate().rev().find_map( + |(index, id)| { reduced.ingests_by_id.get(id).and_then(|ing| { (ing.principal == viewer).then_some((index, ing.ts)) }) - }); - let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); - let line = line_ok(RpcResult::Feed(rpc_feed( - &reduced, - &viewer, - None, - since, - implicit_anchor, - limit, - ))); - drop(reduced); - line - } + }, + ); + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + let line = line_ok(RpcResult::Feed(rpc_feed( + &reduced, + &viewer, + None, + since, + implicit_anchor, + limit, + ))); + drop(reduced); + line } } } - }, - RpcCommand::PostRedact { post_id } => { - match rpc_post_redact(&state, &headers, post_id).await { - Ok(r) => line_ok(r), - Err((e, h)) => line_err(e, h), - } } - }; - results.push(line); + } + RpcCommand::PostRedact { post_id } => { + match rpc_post_redact(state, headers, post_id).await { + Ok(r) => line_ok(r), + Err((e, h)) => line_err(e, h), + } + } } - - Json(RpcBatchResponse { results }).into_response() } diff --git a/server/src/lib.rs b/server/src/lib.rs index 7845de0b8512738fe20ca9dffd84e9b8c8134d50..511651c1bf21a11dc597e36eafc151487c2f82df 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -5,15 +5,16 @@ pub mod canonical_path; pub mod dsl; pub mod event_log; pub mod events; -pub mod resolvers; pub mod form_template; pub mod html; pub mod identity; +pub mod mcp; pub mod middleware; pub mod offline; pub mod path_types; pub mod ranking; pub mod reducer; +pub mod resolvers; pub mod scope_rank; pub mod state; pub mod stationary; @@ -47,6 +48,7 @@ pub fn create_app_state(cfg: AppConfig) -> AppState { event_log: Arc::new(event_log), reduced: Arc::new(RwLock::new(crate::reducer::ReducerState::default())), pending_sessions: Arc::new(RwLock::new(HashMap::new())), + mcp_oauth_codes: Arc::new(RwLock::new(HashMap::new())), invites: Arc::new(RwLock::new(HashMap::new())), stream_tx, js_tx, @@ -123,6 +125,7 @@ pub fn create_app(state: AppState) -> Router { .route("/api/v0/pending-session/:id", get(api::get_pending_session)) .route("/api/v0/whoami", get(api::get_whoami)) .route("/api/v0/rpc", post(api::handle_rpc_batch)) + .merge(crate::mcp::mcp_routes()) .layer(axum::middleware::from_fn_with_state( state.clone(), crate::middleware::view_count_middleware, diff --git a/server/src/mcp/mod.rs b/server/src/mcp/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..6f04c774f6274af040343c55e45d8df926c76ce7 --- /dev/null +++ b/server/src/mcp/mod.rs @@ -0,0 +1,824 @@ +//! ChatGPT / Codex MCP app surface (`POST /mcp`). +//! +//! Streamable HTTP with JSON request/response. Tools call [`crate::api::dispatch_rpc`] +//! so garden/forum writes still go through the same event-log path as `POST /api/v0/rpc`. + +pub mod oauth; + +use axum::{ + extract::State, + http::{header, HeaderMap, StatusCode}, + response::IntoResponse, + Json, +}; +use serde_json::{json, Value}; +use slug_types::*; + +use crate::{api::dispatch_rpc, state::AppState}; + +use self::oauth::{cors_headers, www_authenticate_challenge}; + +const SERVER_NAME: &str = "slug-social"; +const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); +const INSTRUCTIONS: &str = "\ +Slug is a garden (path-addressed ontology + pairwise rank centrality) and a forum \ +(bump-ordered threads). Read tools work anonymously on room public. Before posting \ +a comparison, call get_pair or get_item, ask the human for their view, draft a \ +.sorter document, call check_sorter, then post_sorter. Do not invent delegate \ +UUIDs. Cite the url fields returned by tools. Writes require the user to link \ +their slug.social account."; + +pub async fn mcp_options() -> impl IntoResponse { + let mut res = StatusCode::NO_CONTENT.into_response(); + cors_headers(res.headers_mut()); + res +} + +pub async fn mcp_get() -> impl IntoResponse { + let mut res = ( + StatusCode::METHOD_NOT_ALLOWED, + Json(json!({"error": "use POST /mcp for JSON-RPC"})), + ) + .into_response(); + cors_headers(res.headers_mut()); + res +} + +pub async fn mcp_delete() -> impl IntoResponse { + let mut res = StatusCode::NO_CONTENT.into_response(); + cors_headers(res.headers_mut()); + res +} + +pub async fn mcp_post( + State(state): State, + headers: HeaderMap, + body: Json, +) -> impl IntoResponse { + let response = handle_mcp_body(&state, &headers, body.0).await; + let mut res = Json(response).into_response(); + cors_headers(res.headers_mut()); + res +} + +async fn handle_mcp_body(state: &AppState, headers: &HeaderMap, body: Value) -> Value { + if let Some(arr) = body.as_array() { + let mut out = Vec::with_capacity(arr.len()); + for item in arr { + if let Some(resp) = handle_rpc(state, headers, item).await { + out.push(resp); + } + } + return Value::Array(out); + } + handle_rpc(state, headers, &body).await.unwrap_or( + json!({"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"invalid request"}}), + ) +} + +async fn handle_rpc(state: &AppState, headers: &HeaderMap, body: &Value) -> Option { + if body.get("jsonrpc").and_then(|v| v.as_str()) != Some("2.0") { + return Some(json!({ + "jsonrpc": "2.0", + "id": body.get("id").cloned().unwrap_or(Value::Null), + "error": {"code": -32600, "message": "jsonrpc must be 2.0"} + })); + } + let method = body.get("method").and_then(|v| v.as_str()).unwrap_or(""); + let id = body.get("id").cloned(); + let params = body.get("params").cloned().unwrap_or(json!({})); + if id.is_none() { + return None; + } + let id = id.unwrap(); + let result = match method { + "initialize" => initialize(¶ms), + "ping" => json!({}), + "tools/list" => tools_list(), + "tools/call" => tools_call(state, headers, ¶ms).await, + other => { + return Some(json!({ + "jsonrpc": "2.0", + "id": id, + "error": {"code": -32601, "message": format!("method not found: {other}")} + })); + } + }; + Some(json!({"jsonrpc": "2.0", "id": id, "result": result})) +} + +fn initialize(params: &Value) -> Value { + let requested = params + .get("protocolVersion") + .and_then(|v| v.as_str()) + .unwrap_or("2025-03-26"); + let protocol_version = match requested { + "2024-11-05" | "2025-03-26" | "2025-11-25" | "2026-07-28" => requested, + _ => "2025-03-26", + }; + json!({ + "protocolVersion": protocol_version, + "capabilities": { + "tools": {"listChanged": false} + }, + "serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION, + "title": "slug.social" + }, + "instructions": INSTRUCTIONS + }) +} + +fn annotations(read_only: bool, open_world: bool, destructive: bool) -> Value { + json!({ + "readOnlyHint": read_only, + "openWorldHint": open_world, + "destructiveHint": destructive, + }) +} + +fn noauth() -> Value { + json!([{"type": "noauth"}]) +} + +fn oauth_write() -> Value { + json!([{"type": "oauth2", "scopes": ["slug.write"]}]) +} + +fn tool( + name: &str, + title: &str, + description: &str, + input: Value, + output: Value, + ann: Value, + schemes: Value, +) -> Value { + json!({ + "name": name, + "title": title, + "description": description, + "inputSchema": input, + "outputSchema": output, + "annotations": ann, + "securitySchemes": schemes, + "_meta": { "securitySchemes": schemes } + }) +} + +fn tools_list() -> Value { + json!({ + "tools": [ + tool( + "search", + "Search slug", + "Search public garden items, forum threads, and posts. Use this first when the user asks about something on slug.social.", + json!({ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"} + }, + "required": ["query"] + }), + json!({ + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + "url": {"type": "string"} + }, + "required": ["id", "title", "url"] + } + } + }, + "required": ["results"] + }), + annotations(true, false, false), + noauth(), + ), + tool( + "fetch", + "Fetch slug document", + "Open one search hit by id (item:, thread:, or post:). Returns full text and a citation URL.", + json!({ + "type": "object", + "properties": { + "id": {"type": "string", "description": "Id from search, e.g. item:~/languages/rust"} + }, + "required": ["id"] + }), + json!({ + "type": "object", + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + "text": {"type": "string"}, + "url": {"type": "string"}, + "metadata": {"type": "object"} + }, + "required": ["id", "title", "text", "url"] + }), + annotations(true, false, false), + noauth(), + ), + tool( + "list_threads", + "List forum threads", + "List recently active public forum threads (bump-ordered).", + json!({"type": "object", "properties": {}}), + json!({"type": "object"}), + annotations(true, false, false), + noauth(), + ), + tool( + "get_thread", + "Read a forum thread", + "Read a public forum thread page. Use the tag without #.", + json!({ + "type": "object", + "properties": { + "thread_tag": {"type": "string"}, + "offset": {"type": "integer"}, + "limit": {"type": "integer"}, + "post_id": {"type": "string"} + }, + "required": ["thread_tag"] + }), + json!({"type": "object"}), + annotations(true, false, false), + noauth(), + ), + tool( + "get_rank", + "Garden ranking", + "Ranked children under a garden parent path (e.g. ~ or ~/languages).", + json!({ + "type": "object", + "properties": { + "parent_path": {"type": "string", "description": "Garden parent, default ~"}, + "depth": {"type": "integer"}, + "offset": {"type": "integer"}, + "limit": {"type": "integer"}, + "percent": {"type": "boolean"} + } + }), + json!({"type": "object"}), + annotations(true, false, false), + noauth(), + ), + tool( + "get_item", + "Garden item", + "Item body plus related thread tags for a garden path.", + json!({ + "type": "object", + "properties": { + "item_path": {"type": "string"}, + "full": {"type": "boolean"} + }, + "required": ["item_path"] + }), + json!({"type": "object"}), + annotations(true, false, false), + noauth(), + ), + tool( + "get_pair", + "Next comparison pair", + "Suggest the next pairwise comparison under a garden parent path.", + json!({ + "type": "object", + "properties": { + "parent_path": {"type": "string", "description": "Garden parent, default ~"} + } + }), + json!({"type": "object"}), + annotations(true, false, false), + noauth(), + ), + tool( + "check_sorter", + "Dry-run a .sorter document", + "Parse and preview ranking effects of a .sorter document without writing.", + json!({ + "type": "object", + "properties": { + "text": {"type": "string", "description": "Full .sorter document"} + }, + "required": ["text"] + }), + json!({"type": "object"}), + annotations(true, false, false), + noauth(), + ), + tool( + "post_sorter", + "Publish a .sorter document", + "Publish a comparison or item definition to a public forum thread. Requires the user to link slug.social. Ask the human before posting. Do not invent delegate UUIDs.", + json!({ + "type": "object", + "properties": { + "thread_tag": {"type": "string", "description": "Forum tag without #"}, + "text": {"type": "string", "description": "Full .sorter document"} + }, + "required": ["thread_tag", "text"] + }), + json!({"type": "object"}), + annotations(false, true, false), + oauth_write(), + ), + tool( + "redact_post", + "Redact a post", + "Tombstone the signed-in user's post and remove its garden contributions. Irreversible.", + json!({ + "type": "object", + "properties": { + "post_id": {"type": "string"} + }, + "required": ["post_id"] + }), + json!({"type": "object"}), + annotations(false, true, true), + oauth_write(), + ), + ] + }) +} + +fn arg_string(args: &Value, key: &str) -> Option { + args.get(key) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn arg_usize(args: &Value, key: &str) -> Option { + args.get(key).and_then(|v| { + v.as_u64() + .map(|n| n as usize) + .or_else(|| v.as_str()?.parse().ok()) + }) +} + +fn arg_bool(args: &Value, key: &str) -> Option { + args.get(key).and_then(|v| { + v.as_bool().or_else(|| match v.as_str()? { + "true" | "1" => Some(true), + "false" | "0" => Some(false), + _ => None, + }) + }) +} + +fn tool_ok(structured: Value, summary: impl Into) -> Value { + let text = if structured.is_null() { + summary.into() + } else { + structured.to_string() + }; + json!({ + "structuredContent": structured, + "content": [{"type": "text", "text": text}], + "isError": false + }) +} + +fn tool_err(message: impl Into, hint: Option) -> Value { + let mut text = message.into(); + if let Some(h) = hint { + text.push_str(" — "); + text.push_str(&h); + } + json!({ + "structuredContent": {"error": text}, + "content": [{"type": "text", "text": text}], + "isError": true + }) +} + +fn auth_required() -> Value { + let desc = "Link your slug.social account to post or redact."; + json!({ + "structuredContent": {"error": desc}, + "content": [{"type": "text", "text": desc}], + "isError": true, + "_meta": { + "mcp/www_authenticate": [ + www_authenticate_challenge("insufficient_scope", desc) + ] + } + }) +} + +fn bearer_from_headers(headers: &HeaderMap) -> Option { + let v = headers.get(header::AUTHORIZATION)?.to_str().ok()?; + let rest = v.strip_prefix("Bearer ").unwrap_or(v).trim(); + if rest.is_empty() { + None + } else { + Some(rest.to_string()) + } +} + +async fn rpc( + state: &AppState, + headers: &HeaderMap, + cmd: RpcCommand, +) -> Result)> { + let line = dispatch_rpc(state, headers, cmd).await; + if line.ok { + line.result.ok_or_else(|| ("empty result".into(), None)) + } else { + Err((line.error.unwrap_or_else(|| "rpc failed".into()), line.hint)) + } +} + +fn thread_url(tag: &str) -> String { + ForumThreadUrl::from_room_tag("public", tag.trim_start_matches('#')).into_inner() +} + +fn search_results(resp: SearchResponse) -> Value { + let mut results = Vec::new(); + for item in resp.items { + let url = item.path.as_str().to_string(); + results.push(json!({ + "id": format!("item:{url}"), + "title": url, + "url": url + })); + } + for th in resp.threads { + let tag = th.tag.trim_start_matches('#'); + let url = thread_url(tag); + results.push(json!({ + "id": format!("thread:{tag}"), + "title": format!("#{}", tag), + "url": url + })); + } + for post in resp.posts { + let tag = post.thread.trim_start_matches('#'); + let url = thread_url(tag); + let id = if post.id.is_empty() { + format!("thread:{tag}") + } else { + format!("post:{}", post.id) + }; + let title = post.snippet.lines().next().unwrap_or("post").trim(); + results.push(json!({ + "id": id, + "title": title, + "url": url + })); + } + json!({"results": results}) +} + +async fn tools_call(state: &AppState, headers: &HeaderMap, params: &Value) -> Value { + let name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let args = params.get("arguments").cloned().unwrap_or(json!({})); + match name { + "search" => { + let Some(query) = arg_string(&args, "query") else { + return tool_err("query is required", None); + }; + match rpc(state, headers, RpcCommand::Search { query }).await { + Ok(RpcResult::Search(resp)) => { + let structured = search_results(resp); + let n = structured["results"] + .as_array() + .map(|a| a.len()) + .unwrap_or(0); + tool_ok(structured, format!("Found {n} results.")) + } + Ok(_) => tool_err("unexpected search result", None), + Err((e, h)) => tool_err(e, h), + } + } + "fetch" => fetch_doc(state, headers, &args).await, + "list_threads" => { + match rpc( + state, + headers, + RpcCommand::ListForumThreads { + room: "public".into(), + }, + ) + .await + { + Ok(r) => tool_ok(serde_json::to_value(r).unwrap_or(Value::Null), "threads"), + Err((e, h)) => tool_err(e, h), + } + } + "get_thread" => { + let Some(thread_tag) = arg_string(&args, "thread_tag") else { + return tool_err("thread_tag is required", None); + }; + match rpc( + state, + headers, + RpcCommand::GetForumThread { + room: "public".into(), + thread_tag, + offset: arg_usize(&args, "offset"), + limit: arg_usize(&args, "limit"), + since: None, + before: None, + actor: None, + post_id: arg_string(&args, "post_id"), + }, + ) + .await + { + Ok(r) => tool_ok(serde_json::to_value(r).unwrap_or(Value::Null), "thread"), + Err((e, h)) => tool_err(e, h), + } + } + "get_rank" => { + match rpc( + state, + headers, + RpcCommand::GetGardenRank { + room: "public".into(), + parent_path: arg_string(&args, "parent_path").unwrap_or_else(|| "~".into()), + depth: arg_usize(&args, "depth"), + offset: arg_usize(&args, "offset"), + limit: arg_usize(&args, "limit"), + percent: arg_bool(&args, "percent"), + }, + ) + .await + { + Ok(r) => tool_ok(serde_json::to_value(r).unwrap_or(Value::Null), "rank"), + Err((e, h)) => tool_err(e, h), + } + } + "get_item" => { + let Some(item_path) = arg_string(&args, "item_path") else { + return tool_err("item_path is required", None); + }; + match rpc( + state, + headers, + RpcCommand::GetGardenItem { + room: "public".into(), + item_path, + full: arg_bool(&args, "full"), + }, + ) + .await + { + Ok(r) => tool_ok(serde_json::to_value(r).unwrap_or(Value::Null), "item"), + Err((e, h)) => tool_err(e, h), + } + } + "get_pair" => { + match rpc( + state, + headers, + RpcCommand::GetPair { + room: "public".into(), + parent_path: arg_string(&args, "parent_path").unwrap_or_else(|| "~".into()), + }, + ) + .await + { + Ok(r) => tool_ok(serde_json::to_value(r).unwrap_or(Value::Null), "pair"), + Err((e, h)) => tool_err(e, h), + } + } + "check_sorter" => { + let Some(text) = arg_string(&args, "text") else { + return tool_err("text is required", None); + }; + match rpc( + state, + headers, + RpcCommand::Check { + room: "public".into(), + text, + }, + ) + .await + { + Ok(r) => tool_ok(serde_json::to_value(r).unwrap_or(Value::Null), "check"), + Err((e, h)) => tool_err(e, h), + } + } + "post_sorter" => { + if bearer_from_headers(headers).is_none() { + return auth_required(); + } + let Some(thread_tag) = arg_string(&args, "thread_tag") else { + return tool_err("thread_tag is required", None); + }; + let Some(text) = arg_string(&args, "text") else { + return tool_err("text is required", None); + }; + match rpc( + state, + headers, + RpcCommand::Post { + room: "public".into(), + thread_tag, + delegate: None, + text, + return_rank_diff: true, + }, + ) + .await + { + Ok(r) => tool_ok(serde_json::to_value(r).unwrap_or(Value::Null), "posted"), + Err((e, h)) => { + if e.contains("Authorization") || e.contains("token") || e.contains("Bearer") { + auth_required() + } else { + tool_err(e, h) + } + } + } + } + "redact_post" => { + if bearer_from_headers(headers).is_none() { + return auth_required(); + } + let Some(post_id) = arg_string(&args, "post_id") else { + return tool_err("post_id is required", None); + }; + match rpc(state, headers, RpcCommand::PostRedact { post_id }).await { + Ok(r) => tool_ok(serde_json::to_value(r).unwrap_or(Value::Null), "redacted"), + Err((e, h)) => { + if e.contains("Authorization") || e.contains("token") || e.contains("Bearer") { + auth_required() + } else { + tool_err(e, h) + } + } + } + } + "" => tool_err("tool name is required", None), + other => tool_err(format!("unknown tool: {other}"), None), + } +} + +async fn fetch_doc(state: &AppState, headers: &HeaderMap, args: &Value) -> Value { + let Some(raw_id) = arg_string(args, "id") else { + return tool_err("id is required", None); + }; + let id = if raw_id.starts_with("item:") + || raw_id.starts_with("thread:") + || raw_id.starts_with("post:") + { + raw_id + } else if raw_id.contains('/') || raw_id.starts_with('~') || raw_id.starts_with("http") { + format!("item:{raw_id}") + } else { + format!("thread:{raw_id}") + }; + if let Some(path) = id.strip_prefix("item:") { + match rpc( + state, + headers, + RpcCommand::GetGardenItem { + room: "public".into(), + item_path: path.to_string(), + full: Some(true), + }, + ) + .await + { + Ok(RpcResult::GardenItem(item)) => { + let url = item.item.as_str().to_string(); + let text = item.body.clone().unwrap_or_default(); + let structured = json!({ + "id": id, + "title": url, + "text": text, + "url": url, + "metadata": {"threads": item.threads, "truncated": item.truncated} + }); + tool_ok(structured, "item") + } + Ok(_) => tool_err("unexpected item result", None), + Err((e, h)) => tool_err(e, h), + } + } else if let Some(tag) = id.strip_prefix("thread:") { + match rpc( + state, + headers, + RpcCommand::GetForumThread { + room: "public".into(), + thread_tag: tag.to_string(), + offset: Some(0), + limit: Some(50), + since: None, + before: None, + actor: None, + post_id: None, + }, + ) + .await + { + Ok(RpcResult::ForumThread(th)) => { + let url = thread_url(tag); + let text = serde_json::to_string_pretty(&th).unwrap_or_default(); + let structured = json!({ + "id": id, + "title": format!("#{}", tag.trim_start_matches('#')), + "text": text, + "url": url, + "metadata": {"total": th.total} + }); + tool_ok(structured, "thread") + } + Ok(_) => tool_err("unexpected thread result", None), + Err((e, h)) => tool_err(e, h), + } + } else if let Some(post_id) = id.strip_prefix("post:") { + let thread_tag = { + let reduced = state.reduced.read().await; + reduced + .ingests_by_id + .get(post_id) + .map(|ing| ing.thread_tag.clone()) + }; + let Some(thread_tag) = thread_tag else { + return tool_err("post not found", None); + }; + match rpc( + state, + headers, + RpcCommand::GetForumThread { + room: "public".into(), + thread_tag, + offset: None, + limit: None, + since: None, + before: None, + actor: None, + post_id: Some(post_id.to_string()), + }, + ) + .await + { + Ok(RpcResult::ForumThread(th)) => { + let url = thread_url(th.thread.trim_start_matches('#')); + let text = match th.items.first() { + Some(ThreadItem::Post { body, .. }) => body.clone(), + Some(ThreadItem::System { text, .. }) => text.clone(), + None => String::new(), + }; + let structured = json!({ + "id": id, + "title": th.thread, + "text": text, + "url": url, + "metadata": {"thread": th.thread} + }); + tool_ok(structured, "post") + } + Ok(_) => tool_err("unexpected post result", None), + Err((e, h)) => tool_err(e, h), + } + } else { + tool_err(format!("unknown fetch id: {id}"), None) + } +} + +pub fn mcp_routes() -> axum::Router { + use axum::routing::{get, post}; + axum::Router::new() + .route( + "/mcp", + post(mcp_post) + .get(mcp_get) + .delete(mcp_delete) + .options(mcp_options), + ) + .route( + "/.well-known/oauth-protected-resource", + get(oauth::oauth_protected_resource).options(mcp_options), + ) + .route( + "/.well-known/oauth-authorization-server", + get(oauth::oauth_authorization_server).options(mcp_options), + ) + .route( + "/.well-known/openid-configuration", + get(oauth::oauth_authorization_server).options(mcp_options), + ) + .route( + "/.well-known/openai-apps-challenge", + get(oauth::openai_apps_challenge), + ) + .route("/oauth/authorize", get(oauth::oauth_authorize)) + .route("/oauth/token", post(oauth::oauth_token)) +} diff --git a/server/src/mcp/oauth.rs b/server/src/mcp/oauth.rs new file mode 100644 index 0000000000000000000000000000000000000000..815667176042ecc49aed381e141de490a186d8e2 --- /dev/null +++ b/server/src/mcp/oauth.rs @@ -0,0 +1,466 @@ +//! Thin OAuth 2.1 authorization server for ChatGPT / Codex MCP clients. +//! +//! ChatGPT is the OAuth client. Humans still sign in with the existing Google +//! login. On success we mint a one-time authorization code and the token +//! endpoint returns the same `slug_…` bearer the rest of the app already +//! verifies. + +use axum::{ + extract::{Query, State}, + http::{header, HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Redirect, Response}, + Form, Json, +}; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::{ + api::{now_ms, public_url}, + state::{AppState, McpOauthCode, McpOauthRequest, PendingSession}, +}; + +const CODE_TTL_MS: i64 = 10 * 60 * 1000; + +pub fn mcp_resource_url() -> String { + format!("{}/mcp", public_url()) +} + +pub fn issuer_url() -> String { + public_url() +} + +fn authorization_endpoint() -> String { + format!("{}/oauth/authorize", public_url()) +} + +fn token_endpoint() -> String { + format!("{}/oauth/token", public_url()) +} + +fn protected_resource_metadata_url() -> String { + format!("{}/.well-known/oauth-protected-resource", public_url()) +} + +pub fn www_authenticate_challenge(error: &str, description: &str) -> String { + format!( + "Bearer resource_metadata=\"{}\", error=\"{}\", error_description=\"{}\"", + protected_resource_metadata_url(), + error, + description.replace('"', "'") + ) +} + +pub async fn oauth_protected_resource() -> impl IntoResponse { + let resource = mcp_resource_url(); + Json(serde_json::json!({ + "resource": resource, + "authorization_servers": [issuer_url()], + "scopes_supported": ["slug.read", "slug.write"], + "resource_documentation": format!("{}/", public_url()), + })) +} + +pub async fn oauth_authorization_server() -> impl IntoResponse { + Json(serde_json::json!({ + "issuer": issuer_url(), + "authorization_endpoint": authorization_endpoint(), + "token_endpoint": token_endpoint(), + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": ["none"], + "client_id_metadata_document_supported": true, + "authorization_response_iss_parameter_supported": true, + "scopes_supported": ["slug.read", "slug.write"], + })) +} + +pub async fn openai_apps_challenge() -> impl IntoResponse { + match std::env::var("SLUG_OPENAI_APPS_CHALLENGE") { + Ok(token) if !token.trim().is_empty() => { + let mut res = token.trim().to_string().into_response(); + res.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + res + } + _ => StatusCode::NOT_FOUND.into_response(), + } +} + +#[derive(Debug, Deserialize)] +pub struct AuthorizeQuery { + pub response_type: Option, + pub client_id: Option, + pub redirect_uri: Option, + pub code_challenge: Option, + pub code_challenge_method: Option, + pub state: Option, + pub resource: Option, + pub scope: Option, +} + +pub fn redirect_uri_allowed(redirect_uri: &str) -> bool { + let Ok(url) = url::Url::parse(redirect_uri) else { + return false; + }; + match url.scheme() { + "https" => { + let host = url.host_str().unwrap_or_default(); + host == "chatgpt.com" + || host.ends_with(".chatgpt.com") + || host == "chat.openai.com" + || host.ends_with(".chat.openai.com") + } + "http" => { + let host = url.host_str().unwrap_or_default(); + host == "127.0.0.1" || host == "localhost" || host == "[::1]" + } + _ => false, + } +} + +fn authorize_error_redirect( + redirect_uri: &str, + state: Option<&str>, + error: &str, + desc: &str, +) -> Response { + let mut url = match url::Url::parse(redirect_uri) { + Ok(u) => u, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + format!("invalid redirect_uri and {error}: {desc}"), + ) + .into_response(); + } + }; + url.query_pairs_mut() + .append_pair("error", error) + .append_pair("error_description", desc) + .append_pair("iss", &issuer_url()); + if let Some(state) = state { + url.query_pairs_mut().append_pair("state", state); + } + Redirect::temporary(url.as_str()).into_response() +} + +pub async fn oauth_authorize( + Query(q): Query, + State(state): State, +) -> impl IntoResponse { + let redirect_uri = q.redirect_uri.clone().unwrap_or_default(); + let state_q = q.state.clone(); + if !redirect_uri_allowed(&redirect_uri) { + return ( + StatusCode::BAD_REQUEST, + "redirect_uri is not an allowed ChatGPT or localhost callback", + ) + .into_response(); + } + if q.response_type.as_deref() != Some("code") { + return authorize_error_redirect( + &redirect_uri, + state_q.as_deref(), + "unsupported_response_type", + "only response_type=code is supported", + ); + } + if q.code_challenge_method.as_deref() != Some("S256") { + return authorize_error_redirect( + &redirect_uri, + state_q.as_deref(), + "invalid_request", + "code_challenge_method must be S256", + ); + } + let Some(code_challenge) = q + .code_challenge + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + else { + return authorize_error_redirect( + &redirect_uri, + state_q.as_deref(), + "invalid_request", + "code_challenge is required", + ); + }; + let Some(client_id) = q + .client_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + else { + return authorize_error_redirect( + &redirect_uri, + state_q.as_deref(), + "invalid_request", + "client_id is required", + ); + }; + + let expected_resource = mcp_resource_url(); + let resource = q + .resource + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(&expected_resource) + .to_string(); + if resource != expected_resource { + return authorize_error_redirect( + &redirect_uri, + state_q.as_deref(), + "invalid_target", + "resource must be this server's /mcp URL", + ); + } + + let session = format!("p_{}", uuid::Uuid::new_v4().simple()); + let pending = PendingSession { + agent: None, + created_ts: now_ms(), + provider: None, + provider_id: None, + redeem_invite: None, + redirect_next: None, + mcp_oauth: Some(McpOauthRequest { + client_id, + redirect_uri, + state: state_q, + code_challenge, + resource, + scope: q.scope, + }), + complete: None, + }; + state + .pending_sessions + .write() + .await + .insert(session.clone(), pending); + + Redirect::temporary(&format!( + "{}/auth/login?session={}", + public_url(), + urlencoding::encode(&session) + )) + .into_response() +} + +/// After Google login / username choice, mint a code and send the user back to ChatGPT. +pub async fn finish_mcp_oauth_if_pending( + state: &AppState, + session_id: &str, + username: &str, + bearer: &str, +) -> Option { + let req = { + let sessions = state.pending_sessions.read().await; + sessions.get(session_id)?.mcp_oauth.clone() + }?; + let code = format!("ac_{}", uuid::Uuid::new_v4().simple()); + state.mcp_oauth_codes.write().await.insert( + code.clone(), + McpOauthCode { + username: username.to_string(), + bearer: bearer.to_string(), + client_id: req.client_id, + redirect_uri: req.redirect_uri.clone(), + code_challenge: req.code_challenge, + resource: req.resource, + created_ts: now_ms(), + }, + ); + let mut url = url::Url::parse(&req.redirect_uri).ok()?; + url.query_pairs_mut() + .append_pair("code", &code) + .append_pair("iss", &issuer_url()); + if let Some(st) = req.state.as_deref() { + url.query_pairs_mut().append_pair("state", st); + } + Some(url.to_string()) +} + +#[derive(Debug, Deserialize)] +pub struct TokenForm { + pub grant_type: Option, + pub code: Option, + pub code_verifier: Option, + pub redirect_uri: Option, + pub client_id: Option, + pub resource: Option, +} + +#[derive(Debug, Serialize)] +struct TokenError { + error: String, + #[serde(skip_serializing_if = "Option::is_none")] + error_description: Option, +} + +fn token_err(status: StatusCode, error: &str, desc: &str) -> Response { + ( + status, + Json(TokenError { + error: error.to_string(), + error_description: Some(desc.to_string()), + }), + ) + .into_response() +} + +pub fn pkce_s256_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) +} + +pub async fn oauth_token( + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + if form.grant_type.as_deref() != Some("authorization_code") { + return token_err( + StatusCode::BAD_REQUEST, + "unsupported_grant_type", + "only authorization_code is supported", + ); + } + let Some(code) = form + .code + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + else { + return token_err( + StatusCode::BAD_REQUEST, + "invalid_request", + "code is required", + ); + }; + let Some(verifier) = form + .code_verifier + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + else { + return token_err( + StatusCode::BAD_REQUEST, + "invalid_request", + "code_verifier is required", + ); + }; + + let grant = { + let mut codes = state.mcp_oauth_codes.write().await; + codes.remove(code) + }; + let Some(grant) = grant else { + return token_err( + StatusCode::BAD_REQUEST, + "invalid_grant", + "unknown or reused code", + ); + }; + if now_ms().saturating_sub(grant.created_ts) > CODE_TTL_MS { + return token_err(StatusCode::BAD_REQUEST, "invalid_grant", "code expired"); + } + if let Some(client_id) = form.client_id.as_deref() { + if client_id != grant.client_id { + return token_err( + StatusCode::BAD_REQUEST, + "invalid_grant", + "client_id mismatch", + ); + } + } + if let Some(redirect_uri) = form.redirect_uri.as_deref() { + if redirect_uri != grant.redirect_uri { + return token_err( + StatusCode::BAD_REQUEST, + "invalid_grant", + "redirect_uri mismatch", + ); + } + } + if let Some(resource) = form.resource.as_deref().filter(|s| !s.is_empty()) { + if resource != grant.resource { + return token_err( + StatusCode::BAD_REQUEST, + "invalid_target", + "resource mismatch", + ); + } + } + if pkce_s256_challenge(verifier) != grant.code_challenge { + return token_err( + StatusCode::BAD_REQUEST, + "invalid_grant", + "PKCE verification failed", + ); + } + + Json(serde_json::json!({ + "access_token": grant.bearer, + "token_type": "Bearer", + "expires_in": 31536000, + "scope": "slug.read slug.write", + })) + .into_response() +} + +pub fn cors_headers(headers: &mut HeaderMap) { + headers.insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, POST, DELETE, OPTIONS"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static( + "authorization, content-type, mcp-session-id, mcp-protocol-version, accept", + ), + ); + headers.insert( + header::ACCESS_CONTROL_EXPOSE_HEADERS, + HeaderValue::from_static("www-authenticate, mcp-session-id"), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pkce_s256_matches_rfc7636_example() { + // RFC 7636 appendix B + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assert_eq!( + pkce_s256_challenge(verifier), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn chatgpt_redirect_allowed() { + assert!(redirect_uri_allowed( + "https://chatgpt.com/connector_platform_oauth_redirect" + )); + assert!(redirect_uri_allowed( + "https://chatgpt.com/connector/oauth/abc" + )); + assert!(redirect_uri_allowed("http://127.0.0.1:9/cb")); + assert!(!redirect_uri_allowed("https://evil.example/cb")); + assert!(!redirect_uri_allowed("/local")); + } +} diff --git a/server/src/middleware.rs b/server/src/middleware.rs index 458bd65054bb1baa70691b06ec44ae71b72b47c5..e4fd81806643ec9f45aad7a64d26705af680d463 100644 --- a/server/src/middleware.rs +++ b/server/src/middleware.rs @@ -9,7 +9,9 @@ use crate::state::AppState; pub fn canonical_view_url(uri: &axum::http::Uri) -> String { let path = uri.path(); if let Some(query) = uri.query() { - let mut pairs: Vec<_> = url::form_urlencoded::parse(query.as_bytes()).into_owned().collect(); + let mut pairs: Vec<_> = url::form_urlencoded::parse(query.as_bytes()) + .into_owned() + .collect(); if pairs.is_empty() { return path.to_string(); } @@ -36,6 +38,9 @@ pub async fn view_count_middleware( && !path.starts_with("/api") && !path.starts_with("/sse") && !path.starts_with("/auth") + && !path.starts_with("/oauth") + && !path.starts_with("/.well-known") + && path != "/mcp" && path != "/healthz" && path != "/ui" { diff --git a/server/src/state.rs b/server/src/state.rs index eb4304c8c76007c453ebe54f1789d0a396329bcd..cfa5aa8b3606333633e7516506a6158ce57b50ee 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -4,8 +4,8 @@ use std::sync::Arc; use tokio::sync::{broadcast, mpsc, RwLock}; use crate::{ - event_log::EventLog, events::ThreadCapability, resolvers::GitHubResolver, - reducer::ReducerState, write_cmd::WriteCmd, + event_log::EventLog, events::ThreadCapability, reducer::ReducerState, + resolvers::GitHubResolver, write_cmd::WriteCmd, }; /// Ephemeral invite link (24h TTL, in-memory only; not written to the event log). @@ -30,9 +30,34 @@ pub struct PendingSession { pub redeem_invite: Option, /// Local path to navigate to after browser onboarding completes. pub redirect_next: Option, + /// ChatGPT / MCP OAuth 2.1 authorize request waiting on this Google login. + pub mcp_oauth: Option, pub complete: Option<(String /*username*/, String /*bearer*/)>, } +/// In-flight MCP authorization-code request (RAM only, like [`PendingSession`]). +#[derive(Debug, Clone)] +pub struct McpOauthRequest { + pub client_id: String, + pub redirect_uri: String, + pub state: Option, + pub code_challenge: String, + pub resource: String, + pub scope: Option, +} + +/// One-time authorization code minted after the human finishes Google login. +#[derive(Debug, Clone)] +pub struct McpOauthCode { + pub username: String, + pub bearer: String, + pub client_id: String, + pub redirect_uri: String, + pub code_challenge: String, + pub resource: String, + pub created_ts: i64, +} + /// An SSE event broadcast to all live stream subscribers when an ingest occurs. #[derive(Debug, Clone, serde::Serialize)] pub struct StreamEvent { @@ -73,6 +98,8 @@ pub struct AppState { pub event_log: Arc, pub reduced: Arc>, pub pending_sessions: Arc>>, + /// MCP OAuth authorization codes (`code` → grant). RAM only; 10-minute TTL. + pub mcp_oauth_codes: Arc>>, /// Ephemeral invite tokens (`inv_…`): in this process only, not written to the event log. /// Minted via `RoomMintInvite`; restarting the server drops any unused links. (Log event /// types `InviteMinted` / `InviteRedeemed` exist for replay and a possible future persisted path.) @@ -108,6 +135,7 @@ impl AppState { event_log: Arc::new(event_log), reduced: Arc::new(RwLock::new(ReducerState::default())), pending_sessions: Arc::new(RwLock::new(HashMap::new())), + mcp_oauth_codes: Arc::new(RwLock::new(HashMap::new())), invites: Arc::new(RwLock::new(HashMap::new())), stream_tx, js_tx, diff --git a/server/tests/integration_mcp.rs b/server/tests/integration_mcp.rs new file mode 100644 index 0000000000000000000000000000000000000000..848c05ebb70e1ece1e03159312d2b88a91b4ad96 --- /dev/null +++ b/server/tests/integration_mcp.rs @@ -0,0 +1,387 @@ +mod support; + +use slugsocial_server::mcp::oauth::{finish_mcp_oauth_if_pending, pkce_s256_challenge}; +use support::*; + +async fn mcp_call( + client: &reqwest::Client, + addr: std::net::SocketAddr, + method: &str, + params: serde_json::Value, + bearer: Option<&str>, +) -> serde_json::Value { + let url = format!("http://{addr}/mcp"); + let mut req = client.post(url).json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params + })); + if let Some(b) = bearer { + req = req.header("Authorization", format!("Bearer {b}")); + } + let response = req.send().await.unwrap(); + assert!( + response.status().is_success(), + "mcp http {} {}", + response.status(), + response.text().await.unwrap_or_default() + ); + response.json().await.unwrap() +} + +async fn tool_call( + client: &reqwest::Client, + addr: std::net::SocketAddr, + name: &str, + arguments: serde_json::Value, + bearer: Option<&str>, +) -> serde_json::Value { + let body = mcp_call( + client, + addr, + "tools/call", + serde_json::json!({"name": name, "arguments": arguments}), + bearer, + ) + .await; + body["result"].clone() +} + +#[tokio::test] +async fn mcp_initialize_and_lists_v1_tools() { + let (addr, _tmp, _log, _handle) = create_test_server().await; + let client = reqwest::Client::new(); + + let init = mcp_call( + &client, + addr, + "initialize", + serde_json::json!({ + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "test", "version": "0"} + }), + None, + ) + .await; + assert_eq!(init["result"]["serverInfo"]["name"], "slug-social"); + assert!(init["result"]["instructions"] + .as_str() + .unwrap() + .contains("ask the human")); + + let listed = mcp_call(&client, addr, "tools/list", serde_json::json!({}), None).await; + let tools = listed["result"]["tools"].as_array().unwrap(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + for expected in [ + "search", + "fetch", + "list_threads", + "get_thread", + "get_rank", + "get_item", + "get_pair", + "check_sorter", + "post_sorter", + "redact_post", + ] { + assert!(names.contains(&expected), "missing {expected} in {names:?}"); + } + let post = tools.iter().find(|t| t["name"] == "post_sorter").unwrap(); + assert_eq!(post["annotations"]["readOnlyHint"], false); + assert_eq!(post["annotations"]["openWorldHint"], true); + assert_eq!(post["annotations"]["destructiveHint"], false); + assert_eq!(post["securitySchemes"][0]["type"], "oauth2"); + let search = tools.iter().find(|t| t["name"] == "search").unwrap(); + assert_eq!(search["annotations"]["readOnlyHint"], true); + assert_eq!(search["securitySchemes"][0]["type"], "noauth"); +} + +#[tokio::test] +async fn mcp_read_and_write_tools_round_trip() { + let (addr, _tmp, _log, _handle) = create_test_server().await; + let client = reqwest::Client::new(); + let bearer = test_bearer(); + let doc = "~/mcp-a {alpha}\n~/mcp-b {beta}\n{because tests}\n~/mcp-a 2:1 ~/mcp-b\n"; + + let unauth = tool_call( + &client, + addr, + "post_sorter", + serde_json::json!({"thread_tag": "mcp-demo", "text": doc}), + None, + ) + .await; + assert_eq!(unauth["isError"], true); + let challenge = unauth["_meta"]["mcp/www_authenticate"][0].as_str().unwrap(); + assert!(challenge.contains("resource_metadata="), "{challenge}"); + assert!(challenge.contains("error="), "{challenge}"); + + let posted = tool_call( + &client, + addr, + "post_sorter", + serde_json::json!({"thread_tag": "mcp-demo", "text": doc}), + Some(&bearer), + ) + .await; + assert_eq!(posted["isError"], false, "{posted}"); + let post_id = posted["structuredContent"]["PostOk"]["post_id"] + .as_str() + .unwrap() + .to_string(); + + let found = tool_call( + &client, + addr, + "search", + serde_json::json!({"query": "mcp-a"}), + None, + ) + .await; + assert_eq!(found["isError"], false, "{found}"); + let results = found["structuredContent"]["results"].as_array().unwrap(); + assert!( + results + .iter() + .any(|r| r["id"].as_str().unwrap().contains("mcp-a")), + "{results:?}" + ); + + let item = tool_call( + &client, + addr, + "fetch", + serde_json::json!({"id": "item:~/mcp-a"}), + None, + ) + .await; + assert_eq!(item["isError"], false, "{item}"); + assert!(item["structuredContent"]["text"] + .as_str() + .unwrap() + .contains("alpha")); + assert!(item["structuredContent"]["url"] + .as_str() + .unwrap() + .contains("mcp-a")); + + let post = tool_call( + &client, + addr, + "fetch", + serde_json::json!({"id": format!("post:{post_id}")}), + None, + ) + .await; + assert_eq!(post["isError"], false, "{post}"); + assert!(post["structuredContent"]["text"] + .as_str() + .unwrap() + .contains("mcp-a")); + + let rank = tool_call( + &client, + addr, + "get_rank", + serde_json::json!({"parent_path": "~"}), + None, + ) + .await; + assert_eq!(rank["isError"], false, "{rank}"); + + let pair = tool_call( + &client, + addr, + "get_pair", + serde_json::json!({"parent_path": "~"}), + None, + ) + .await; + assert_eq!(pair["isError"], false, "{pair}"); + + let threads = tool_call(&client, addr, "list_threads", serde_json::json!({}), None).await; + assert_eq!(threads["isError"], false, "{threads}"); + + let thread = tool_call( + &client, + addr, + "get_thread", + serde_json::json!({"thread_tag": "mcp-demo"}), + None, + ) + .await; + assert_eq!(thread["isError"], false, "{thread}"); + + let check = tool_call( + &client, + addr, + "check_sorter", + serde_json::json!({"text": "{equal}\n~/mcp-a 1:1 ~/mcp-b\n"}), + None, + ) + .await; + assert_eq!(check["isError"], false, "{check}"); + + let redacted = tool_call( + &client, + addr, + "redact_post", + serde_json::json!({"post_id": post_id}), + Some(&bearer), + ) + .await; + assert_eq!(redacted["isError"], false, "{redacted}"); +} + +#[tokio::test] +async fn mcp_oauth_metadata_authorize_and_pkce_token() { + let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + let pr = client + .get(format!( + "http://{addr}/.well-known/oauth-protected-resource" + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert!(pr["resource"].as_str().unwrap().ends_with("/mcp")); + assert_eq!(pr["authorization_servers"][0], "http://127.0.0.1:8080"); + + let as_meta = client + .get(format!( + "http://{addr}/.well-known/oauth-authorization-server" + )) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert_eq!(as_meta["code_challenge_methods_supported"][0], "S256"); + assert_eq!(as_meta["token_endpoint_auth_methods_supported"][0], "none"); + assert_eq!(as_meta["client_id_metadata_document_supported"], true); + + let challenge = client + .get(format!("http://{addr}/.well-known/openai-apps-challenge")) + .send() + .await + .unwrap(); + assert_eq!(challenge.status(), reqwest::StatusCode::NOT_FOUND); + + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + let code_challenge = pkce_s256_challenge(verifier); + let authorize = client + .get(format!("http://{addr}/oauth/authorize")) + .query(&[ + ("response_type", "code"), + ("client_id", "https://chatgpt.com/oauth/client.json"), + ("redirect_uri", "http://127.0.0.1:9/cb"), + ("code_challenge", code_challenge.as_str()), + ("code_challenge_method", "S256"), + ("state", "xyz"), + ("resource", "http://127.0.0.1:8080/mcp"), + ]) + .send() + .await + .unwrap(); + assert_eq!(authorize.status(), reqwest::StatusCode::TEMPORARY_REDIRECT); + let loc = authorize + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert!(loc.contains("/auth/login?session="), "{loc}"); + let session = loc + .split("session=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap() + .to_string(); + let session = urlencoding::decode(&session).unwrap().into_owned(); + + { + let sessions = state.pending_sessions.read().await; + let pending = sessions.get(&session).unwrap(); + assert!(pending.mcp_oauth.is_some()); + } + + let bearer = test_bearer(); + let redirect = finish_mcp_oauth_if_pending(&state, &session, "testuser", &bearer) + .await + .expect("mcp oauth finish"); + assert!(redirect.starts_with("http://127.0.0.1:9/cb?"), "{redirect}"); + assert!(redirect.contains("state=xyz"), "{redirect}"); + assert!(redirect.contains("iss="), "{redirect}"); + let code = url::Url::parse(&redirect) + .unwrap() + .query_pairs() + .find(|(k, _)| k == "code") + .unwrap() + .1 + .into_owned(); + + let token = client + .post(format!("http://{addr}/oauth/token")) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code.as_str()), + ("code_verifier", verifier), + ("redirect_uri", "http://127.0.0.1:9/cb"), + ("client_id", "https://chatgpt.com/oauth/client.json"), + ("resource", "http://127.0.0.1:8080/mcp"), + ]) + .send() + .await + .unwrap(); + assert!(token.status().is_success(), "{}", token.status()); + let token_json: serde_json::Value = token.json().await.unwrap(); + assert_eq!(token_json["access_token"], bearer); + assert_eq!(token_json["token_type"], "Bearer"); + + let replay = client + .post(format!("http://{addr}/oauth/token")) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code.as_str()), + ("code_verifier", verifier), + ]) + .send() + .await + .unwrap(); + assert_eq!(replay.status(), reqwest::StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn mcp_oauth_rejects_foreign_redirect() { + let (addr, _tmp, _log, _handle) = create_test_server().await; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let resp = client + .get(format!("http://{addr}/oauth/authorize")) + .query(&[ + ("response_type", "code"), + ("client_id", "https://chatgpt.com/oauth/client.json"), + ("redirect_uri", "https://evil.example/cb"), + ("code_challenge", "abc"), + ("code_challenge_method", "S256"), + ]) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST); +} diff --git a/types/src/lib.rs b/types/src/lib.rs index 77e9f640894e7ad3d765a9d40063bb72f90f774b..a2215af4bfa9f2310ce563e33cd8596cc72d773e 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -694,6 +694,9 @@ pub struct SearchThreadHit { #[derive(Debug, Serialize, Deserialize)] pub struct SearchPostHit { + /// Stable ingest id. Present for MCP `fetch` and omitted from older cached payloads. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, pub thread: String, pub actor: String, pub snippet: String,