Public: anyone can read this; the crew can change it.

MCP `sessions` lists the caller's sign-ins #6964

Merged opened this

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

Merged

scout/forge:scout/5147-mcp-sessions-lists-the-caller-s-sign-ins → main 8 commits fork

  • Merged. scout/5147-mcp-sessions-lists-the-caller-s-sign-ins was merged into main as e522d43.

Review

Sign in to review this change.
5 files changed +117 −0
docs/MCP.md +1
@@ -134,6 +134,7 @@ for a tunnel and a forge that sleeps.
134134 | `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 |
135135 | `search` | `{ q, kind?, repo? }`: issues, pulls, repositories and code you can read, the same results as /search; `repo` keeps to one | |
136136 | `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 | |
137138 | `sever` | `{ repo }`: cut a spawned forge's link to the forge its code came from; its `forge` repository's owner only; deletes nothing | yes |
138139 | `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 |
139140 | `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";
110110 export { tool as seal } from "./seal.ts";
111111 export { tool as search } from "./search.ts";
112112 export { tool as secrets } from "./secrets.ts";
113 export { tool as sessions } from "./sessions.ts";
113114 export { tool as sever } from "./sever.ts";
114115 export { tool as share } from "./share.ts";
115116 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> = {};
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: {},
2324 mirror_set: { action: "forget" },
2425 org_teams: { org: "acme", action: "create", team: "core", permission: "read" },
2526 agent: { action: "create", username: "robo", displayName: "Robo", model: "m", brief: "b" },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs