Public: anyone can read this; the crew can change it.
MCP `sessions` ends one sign-in, so a lost device is signed out from anywhere #6968
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5148-mcp-sessions-ends-one-sign-in-so-a-lost → main 2 commits fork 2 threads · 2 unresolved
4 files
changed +63
−7
docs/MCP.md +1 −1
| @@ -136,7 +136,7 @@ for a tunnel and a forge that sleeps. | ||
| 136 | 136 | | `seal` | `{ repo, number, sealed }`: seal a security report (and the pull that fixes it) so only writers and the crew see it, or unseal it for every reader; a writer's, recorded; with `action: "private"` instead of `sealed`, the private repository a writer fixes a sealed report in, made on first need, with its name and clone URL (the API's `POST …/issues/:number/private`, same rules) | yes | |
| 137 | 137 | | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | | |
| 138 | 138 | | `secrets` | `{ scope, ref?, action, name?, value? }` (forge admin): `list` the names and timestamps of the forge's sealed secrets, `set` or `remove` one; `scope` is forge, org or repo, `ref` the org or `owner/name`; a value is never read back | yes | |
| 139 | | `sessions` | `{ username? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; an admin-scope token, the route's refusals | | | |
| 139 | | `sessions` | `{ username?, action?, id? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; `action: "end"` with a session `id` signs that browser out, as `DELETE /api/v1/users/:username/sessions/:id` does, and says so when it was this one; an admin-scope token, the route's refusals | yes | | |
| 140 | 140 | | `sever` | `{ repo }`: cut a spawned forge's link to the forge its code came from; its `forge` repository's owner only; deletes nothing | yes | |
| 141 | 141 | | `share` | `{ item, to? }`: share a feed item (`item` is its id); with no `to` it goes to the app's crew and owners, else to up to 10 named usernames' followers; a daily budget each; answers `{ sent }` | yes | |
| 142 | 142 | | `signup` | `{ mode?, dryRun? }`: who may sign up: `open` (default), `invite` (only a person with an open invite link) or `closed` (no new accounts); without `mode` it reads the setting, with `mode` it plans first (`dryRun` is true unless `false`), and applying needs an admin token | yes | |
src/http/mcp/tools/sessions.ts +24 −5
| @@ -1,18 +1,37 @@ | ||
| 1 | import { accountSignIns } from "../../../actions/signins.ts"; | |
| 1 | import { accountSignIns, endAccountSession } from "../../../actions/signins.ts"; | |
| 2 | 2 | import { good, bad, type Tool } from "../shared.ts"; |
| 3 | 3 | |
| 4 | 4 | export const tool: Tool = { |
| 5 | 5 | name: "sessions", |
| 6 | doc: "`{ username? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; an admin-scope token, the route's refusals", | |
| 7 | description: "Where you are signed in: your tokens and browsers, never a secret; `username` for a person as owner.", | |
| 6 | doc: "`{ username?, action?, id? }`: where you (or, as the forge's owner, a person) are signed in: tokens and browsers, as `GET /api/v1/users/:username/signins` lists them, never a secret; `action: \"end\"` with a session `id` signs that browser out, as `DELETE /api/v1/users/:username/sessions/:id` does, and says so when it was this one; an admin-scope token, the route's refusals", | |
| 7 | description: "Where you are signed in: your tokens and browsers, never a secret; `username` for a person as owner; action end + id signs a browser out.", | |
| 8 | 8 | inputSchema: { |
| 9 | 9 | type: "object", |
| 10 | properties: { username: { type: "string", maxLength: 64 } }, | |
| 10 | properties: { | |
| 11 | username: { type: "string", maxLength: 64 }, | |
| 12 | action: { type: "string", enum: ["list", "end"] }, | |
| 13 | id: { type: "string", maxLength: 64 }, | |
| 14 | }, | |
| 11 | 15 | }, |
| 12 | writes: false, | |
| 16 | writes: true, | |
| 17 | readsWhen: (args) => (args["action"] ?? "list") === "list", | |
| 13 | 18 | needsAdminScope: true, |
| 14 | 19 | run(ctx, user, args) { |
| 15 | 20 | const name = typeof args["username"] === "string" && args["username"] !== "" ? args["username"] : user.username; |
| 21 | const action = args["action"] ?? "list"; | |
| 22 | if (action !== "list" && action !== "end") return bad("bad_action", "action is list or end"); | |
| 23 | if (action === "end") { | |
| 24 | const id = args["id"]; | |
| 25 | if (typeof id !== "string" || id === "") return bad("bad_id", "id is a session id from the list"); | |
| 26 | const done = endAccountSession(ctx.app, user, name, id); | |
| 27 | if (!done.ok) return bad("not_found", "no such session"); | |
| 28 | return good({ | |
| 29 | ended: id, | |
| 30 | says: name === user.username | |
| 31 | ? "Signed that browser out of your account; if it is the one you are in now, it is signed out." | |
| 32 | : `Signed that browser out of ${name}.`, | |
| 33 | }); | |
| 34 | } | |
| 16 | 35 | const seen = accountSignIns(ctx.app, user, name); |
| 17 | 36 | if (!seen.ok) return bad("not_found", "no such account"); |
| 18 | 37 | const { tokens, sessions } = seen.value; |
test/mcp-sessions.test.ts +37
| @@ -86,4 +86,41 @@ describe("MCP sessions", () => { | ||
| 86 | 86 | expect(names).not.toContain("sessions"); |
| 87 | 87 | expect((await mcp(rw, {})).error).toBe(true); |
| 88 | 88 | }); |
| 89 | ||
| 90 | const me = async (cookie: string) => (await fetch(`${servers.url}/api/v1/user`, { headers: { cookie: `forge_session=${cookie}` } })).status; | |
| 91 | ||
| 92 | test("end signs one browser out, and its cookie answers as signed out", async () => { | |
| 93 | const lost = createSession(app.db, id("carol")).token; | |
| 94 | const tok = createToken(app.db, id("carol"), "t", "admin").token; | |
| 95 | expect(await me(lost)).toBe(200); | |
| 96 | const list = JSON.parse((await mcp(tok, {})).text).sessions as { id: string }[]; | |
| 97 | const got = await mcp(tok, { action: "end", id: list[list.length - 1]!.id }); | |
| 98 | expect(got.error).toBe(false); | |
| 99 | expect(JSON.parse(got.text).says).toContain("signed out"); | |
| 100 | expect(await me(lost)).toBe(401); | |
| 101 | }); | |
| 102 | ||
| 103 | test("ending your own says it may be the browser you are in; an owner ending another's names them", async () => { | |
| 104 | const tok = createToken(app.db, id("carol"), "t", "admin").token; | |
| 105 | const own = createSession(app.db, id("carol")); | |
| 106 | const sid = (JSON.parse((await mcp(tok, {})).text).sessions as { id: string }[]).at(-1)!.id; | |
| 107 | expect(JSON.parse((await mcp(tok, { action: "end", id: sid })).text).says).toContain("if it is the one you are in now"); | |
| 108 | const bobsId = (JSON.parse((await mcp(createToken(app.db, id("alice"), "t", "admin").token, { username: "bob" })).text).sessions as { id: string }[])[0]!.id; | |
| 109 | const owner = createToken(app.db, id("alice"), "t", "admin").token; | |
| 110 | expect(JSON.parse((await mcp(owner, { username: "bob", action: "end", id: bobsId })).text).says).toContain("out of bob"); | |
| 111 | expect(own.token).toBeTruthy(); | |
Low: |
||
| 112 | }); | |
| 113 | ||
| 114 | test("an unknown id, or another person's session, is refused as the route refuses", async () => { | |
| 115 | const tok = createToken(app.db, id("carol"), "t", "admin").token; | |
| 116 | const bobs = (JSON.parse((await mcp(createToken(app.db, id("bob"), "t", "admin").token, {})).text).sessions as { id: string }[])[0]!.id; | |
| 117 | for (const [args, route] of [[{ action: "end", id: "nope" }, "nope"], [{ action: "end", id: bobs }, bobs]] as const) { | |
| 118 | const got = await mcp(tok, args); | |
| 119 | expect(got.error).toBe(true); | |
| 120 | expect(got.text).toContain("not_found"); | |
| 121 | const res = await fetch(`${servers.url}/api/v1/users/carol/sessions/${route}`, { method: "DELETE", headers: { authorization: `Bearer ${tok}` } }); | |
| 122 | expect(res.status).toBe(404); | |
| 123 | } | |
| 124 | expect((await mcp(tok, { username: "bob", action: "end", id: bobs })).error).toBe(true); | |
| 125 | }); | |
| 89 | 126 | }); |
test/mcp-write-scope.test.ts +1 −1
| @@ -20,7 +20,7 @@ const SKIPPED: Record<string, string> = {}; | ||
| 20 | 20 | /** Valid arguments for each admin-scope tool: were its gate gone, the call would act. */ |
| 21 | 21 | const ADMIN_ARGS: Record<string, Record<string, unknown>> = { |
| 22 | 22 | repo_settings: { repo: "alice/game", visibility: "public" }, |
| 23 | sessions: {}, | |
| 23 | sessions: { action: "end", id: "x" }, | |
| 24 | 24 | mirror_set: { action: "forget" }, |
| 25 | 25 | org_teams: { org: "acme", action: "create", team: "core", permission: "read" }, |
| 26 | 26 | agent: { action: "create", username: "robo", displayName: "Robo", model: "m", brief: "b" }, |
new above
offline
Info: an MCP caller always authenticates with a token, never a cookie, so the session they end can't be the one they're making this call from. "If it is the one you are in now" is a hedge that can never apply on this door. Something like "Signed that browser out of your account." would be plainer. Not blocking.