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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/5148-mcp-sessions-ends-one-sign-in-so-a-lost → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/5148-mcp-sessions-ends-one-sign-in-so-a-lost was merged into main as 0650e5c.

Review

Sign in to review this change.
4 files changed +63 −7
docs/MCP.md +1 −1
@@ -136,7 +136,7 @@ for a tunnel and a forge that sleeps.
136136 | `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 |
137137 | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | |
138138 | `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 |
140140 | `sever` | `{ repo }`: cut a spawned forge's link to the forge its code came from; its `forge` repository's owner only; deletes nothing | yes |
141141 | `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 |
142142 | `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";
22 import { good, bad, type Tool } from "../shared.ts";
33
44 export const tool: Tool = {
55 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.",
88 inputSchema: {
99 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 },
1115 },
12 writes: false,
16 writes: true,
17 readsWhen: (args) => (args["action"] ?? "list") === "list",
1318 needsAdminScope: true,
1419 run(ctx, user, args) {
1520 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

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.

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 }
1635 const seen = accountSignIns(ctx.app, user, name);
1736 if (!seen.ok) return bad("not_found", "no such account");
1837 const { tokens, sessions } = seen.value;
test/mcp-sessions.test.ts +37
@@ -86,4 +86,41 @@ describe("MCP sessions", () => {
8686 expect(names).not.toContain("sessions");
8787 expect((await mcp(rw, {})).error).toBe(true);
8888 });
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: own is only used here to quiet an unused variable, so this assertion tests nothing. Either drop the binding (createSession(app.db, id("carol"));) or use own.token to show the cookie answers 401 after the end, which is the assertion this test is missing.

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 });
89126 });
test/mcp-write-scope.test.ts +1 −1
@@ -20,7 +20,7 @@ const SKIPPED: Record<string, string> = {};
2020 /** Valid arguments for each admin-scope tool: were its gate gone, the call would act. */
2121 const ADMIN_ARGS: Record<string, Record<string, unknown>> = {
2222 repo_settings: { repo: "alice/game", visibility: "public" },
23 sessions: {},
23 sessions: { action: "end", id: "x" },
2424 mirror_set: { action: "forget" },
2525 org_teams: { org: "acme", action: "create", team: "core", permission: "read" },
2626 agent: { action: "create", username: "robo", displayName: "Robo", model: "m", brief: "b" },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs