Public: anyone can read this; the crew can change it.
MCP `sessions` lists the caller's sign-ins #6964
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5147-mcp-sessions-lists-the-caller-s-sign-ins → main 8 commits fork
5 files
changed +117
−0
docs/MCP.md +1
| @@ -134,6 +134,7 @@ for a tunnel and a forge that sleeps. | ||
| 134 | 134 | | `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 | |
| 135 | 135 | | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | | |
| 136 | 136 | | `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 | |
| 137 | | `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 | | | |
| 137 | 138 | | `sever` | `{ repo }`: cut a spawned forge's link to the forge its code came from; its `forge` repository's owner only; deletes nothing | yes | |
| 138 | 139 | | `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 | |
| 139 | 140 | | `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/index.ts +1
| @@ -110,6 +110,7 @@ export { tool as scorecard } from "./scorecard.ts"; | ||
| 110 | 110 | export { tool as seal } from "./seal.ts"; |
| 111 | 111 | export { tool as search } from "./search.ts"; |
| 112 | 112 | export { tool as secrets } from "./secrets.ts"; |
| 113 | export { tool as sessions } from "./sessions.ts"; | |
| 113 | 114 | export { tool as sever } from "./sever.ts"; |
| 114 | 115 | export { tool as share } from "./share.ts"; |
| 115 | 116 | export { tool as signup } from "./signup.ts"; |
src/http/mcp/tools/sessions.ts added +25
| @@ -0,0 +1,25 @@ | ||
| 1 | import { accountSignIns } from "../../../actions/signins.ts"; | |
| 2 | import { good, bad, type Tool } from "../shared.ts"; | |
| 3 | ||
| 4 | export const tool: Tool = { | |
| 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.", | |
| 8 | inputSchema: { | |
| 9 | type: "object", | |
| 10 | properties: { username: { type: "string", maxLength: 64 } }, | |
| 11 | }, | |
| 12 | writes: false, | |
| 13 | needsAdminScope: true, | |
| 14 | run(ctx, user, args) { | |
| 15 | const name = typeof args["username"] === "string" && args["username"] !== "" ? args["username"] : user.username; | |
| 16 | const seen = accountSignIns(ctx.app, user, name); | |
| 17 | if (!seen.ok) return bad("not_found", "no such account"); | |
| 18 | const { tokens, sessions } = seen.value; | |
| 19 | return good({ | |
| 20 | tokens, | |
| 21 | sessions, | |
| 22 | says: `${seen.value.user.username} is signed in with ${sessions.length} browser${sessions.length === 1 ? "" : "s"} and ${tokens.length} token${tokens.length === 1 ? "" : "s"}.`, | |
| 23 | }); | |
| 24 | }, | |
| 25 | }; |
test/mcp-sessions.test.ts added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | /** MCP `sessions`: the sign-ins API, from a harness. */ | |
| 2 | ||
| 3 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createSession } from "../src/core/sessions.ts"; | |
| 9 | import { createToken } from "../src/core/tokens.ts"; | |
| 10 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { visible } from "../src/http/mcp/shared.ts"; | |
| 13 | import { TOOLS } from "../src/http/mcp/tools.ts"; | |
| 14 | import { quietLogs } from "./helpers/globals.ts"; | |
| 15 | ||
| 16 | quietLogs(); | |
| 17 | ||
| 18 | describe("MCP sessions", () => { | |
| 19 | let dataDir = ""; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | const id = (name: string) => getUserByName(app.db, name)!.id; | |
| 23 | const mcp = async (token: string, args: unknown) => { | |
| 24 | const res = await fetch(`${servers.url}/mcp`, { | |
| 25 | method: "POST", | |
| 26 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 27 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sessions", arguments: args } }), | |
| 28 | }); | |
| 29 | const body = (await res.json()) as any; | |
| 30 | if (body.error !== undefined) return { error: true, text: String(body.error.message) }; | |
| 31 | return { error: body.result.isError === true, text: body.result.content[0].text as string }; | |
| 32 | }; | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-sessions-")); | |
| 36 | app = createApp({ dataDir, port: 0 }); | |
| 37 | servers = await serve(app); | |
| 38 | for (const [username, isAdmin] of [["alice", true], ["bob", false], ["carol", false]] as const) { | |
| 39 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-long-sessions-passphrase", isAdmin }); | |
| 40 | if (!made.ok) throw new Error(username); | |
| 41 | } | |
| 42 | }, 30_000); | |
| 43 | ||
| 44 | afterAll(async () => { | |
| 45 | servers.stop(); | |
| 46 | app.close(); | |
| 47 | await rm(dataDir, { recursive: true, force: true }); | |
| 48 | }); | |
| 49 | ||
| 50 | test("a person with two sign-ins sees both, and no secret", async () => { | |
| 51 | const a = createSession(app.db, id("bob")).token; | |
| 52 | const b = createSession(app.db, id("bob")).token; | |
| 53 | const tok = createToken(app.db, id("bob"), "t", "admin").token; | |
| 54 | const got = await mcp(tok, {}); | |
| 55 | expect(got.error).toBe(false); | |
| 56 | const out = JSON.parse(got.text); | |
| 57 | expect(out.sessions).toHaveLength(2); | |
| 58 | expect(out.says).toContain("bob"); | |
| 59 | for (const secret of [a, b, tok]) expect(got.text).not.toContain(secret); | |
| 60 | }); | |
| 61 | ||
| 62 | test("a stranger naming another person is refused as the route refuses", async () => { | |
| 63 | const tok = createToken(app.db, id("carol"), "t", "admin").token; | |
| 64 | const got = await mcp(tok, { username: "bob" }); | |
| 65 | expect(got.error).toBe(true); | |
| 66 | expect(got.text).toContain("not_found"); | |
| 67 | const route = await fetch(`${servers.url}/api/v1/users/bob/signins`, { headers: { authorization: `Bearer ${tok}` } }); | |
| 68 | expect(route.status).toBe(404); | |
| 69 | }); | |
| 70 | ||
| 71 | test("the owner sees a person's; a token below admin scope is refused", async () => { | |
| 72 | const owner = createToken(app.db, id("alice"), "t", "admin").token; | |
| 73 | expect(JSON.parse((await mcp(owner, { username: "bob" })).text).sessions).toHaveLength(2); | |
| 74 | const rw = createToken(app.db, id("bob"), "t2", "rw").token; | |
| 75 | // tools/list is cut to a byte budget, so the manifest's rule is asserted on `visible` itself. | |
| 76 | const tool = TOOLS.find((t) => t.name === "sessions")!; | |
| 77 | const user = getUserByName(app.db, "bob")!; | |
| 78 | expect(visible(tool, { auth: { scope: "rw" } } as any, user)).toBe(false); | |
| 79 | expect(visible(tool, { auth: { scope: "admin" } } as any, user)).toBe(true); | |
| 80 | const list = await fetch(`${servers.url}/mcp`, { | |
| 81 | method: "POST", | |
| 82 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${rw}` }, | |
| 83 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), | |
| 84 | }); | |
| 85 | const names = ((await list.json()) as any).result.tools.map((t: { name: string }) => t.name); | |
| 86 | expect(names).not.toContain("sessions"); | |
| 87 | expect((await mcp(rw, {})).error).toBe(true); | |
| 88 | }); | |
| 89 | }); |
test/mcp-write-scope.test.ts +1
| @@ -20,6 +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 | 24 | mirror_set: { action: "forget" }, |
| 24 | 25 | org_teams: { org: "acme", action: "create", team: "core", permission: "read" }, |
| 25 | 26 | agent: { action: "create", username: "robo", displayName: "Robo", model: "m", brief: "b" }, |
new above
offline