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

MCP `email_verify`: the caller asks for a new verification email, with the same limits as the API #4864

Merged opened this

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

Merged

scout/forge:scout/4834-mcp-email-verify-the-caller-asks-for-a-n → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/4834-mcp-email-verify-the-caller-asks-for-a-n was merged into main as 42a5aaa.

Review

Sign in to review this change.
4 files changed +125 −0
docs/MCP.md +1
@@ -74,6 +74,7 @@ for a tunnel and a forge that sleeps.
7474 | `wait_for` | `{ app, issue, blockedBy, stop? }`: the issue waits for another, or stops with `stop: true`; the page's rules, and the answer carries the issue's URL | yes |
7575 | `hold` | `{ app, pull, why?, release?, dryRun? }`: hold a pull so no rule reviews or lands it, or let it go; a person who can write, never an agent; plans unless `dryRun: false` | yes |
7676 | `app_env` | `{ repo, action, name?, value?, scope? }`: `list` an app's variable names and scopes, `set` one, or `unset` it, in `all` (default), `production` or `preview`; the API's rules and errors, an admin of the repository with an admin-scope token; a value is never read back | yes |
77 | `email_verify` | no input: send the caller a new verification email, with the API's rate limit and errors; the answer never carries the link | yes |
7778 | `plan` | `{ app, issues }`: open up to 12 linked issues, all or none (see below) | yes |
7879
7980 Answers are JSON in a text block; most carry a `says` sentence short enough
src/http/mcp.ts +23
@@ -35,6 +35,8 @@ import { usesByVendor, type VendorUse } from "../cloud/declared.ts";
3535 import { cloudOverview } from "../cloud/overview.ts";
3636 import { PROVIDERS } from "../cloud/providers.ts";
3737 import { changeQuotas, readQuotas } from "../actions/quotas.ts";
38 import { resendVerification } from "../actions/verify.ts";
39 import { CAPPED_WORDS } from "../actions/mail.ts";
3840 import { changeSignup, readSignup } from "../actions/signup.ts";
3941 import { SIGNUP_MODES } from "../settings/signup.ts";
4042 import { QUOTA_KEYS } from "../settings/quotas.ts";
@@ -3666,6 +3668,27 @@ const TOOLS: readonly Tool[] = [
36663668 });
36673669 },
36683670 },
3671 {
3672 name: "email_verify",
3673 description: "Send the caller a new email-verification link; same limits as the API.",
3674 inputSchema: { type: "object", properties: {} },
3675 writes: true,
3676 async run(ctx, user) {
3677 const sent = await resendVerification(ctx.app, user);
3678 if (sent.ok)
3679 return good({
3680 outcome: sent.value,
3681 says: sent.value === "sent" ? "A new verification email is on its way." : "Your email is already verified.",
3682 });
3683 if (sent.error === "too_soon")
3684 return bad("too_soon", "a link was sent a moment ago; check your inbox, or try again in a few minutes");
3685 if (sent.error === "no_mail")

Non-blocking: the no_mail, capped and failed branches repeat the API's wording by hand (api.ts:1354-1360), and no test exercises them over MCP. Moving these sentences next to CAPPED_WORDS in one shared place would keep the two doors from drifting apart. A no_mail case with no mailer in the fixture would also pin this branch. Fine to do as a follow-up.

3686 return bad("no_mail", "this forge sends no mail yet; ask its owner to verify you");
3687 if (sent.error === "capped") return bad("capped", CAPPED_WORDS);
3688 if (sent.error === "unauthorized") return bad("unauthorized", "sign in first");
3689 return bad("failed", "the link could not be sent; try again");
3690 },
3691 },
36693692 {
36703693 name: "boards",
36713694 description:
test/mcp-email-verify.test.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * The `email_verify` tool is POST /api/v1/user/email/verify over MCP: the
3 * same action, the same limit, and never the link in the answer.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { setLimit } from "../src/settings/limits.ts";
11 import { createToken } from "../src/core/tokens.ts";
12 import { getUserByName } from "../src/core/users.ts";
13 import { emailVerified } from "../src/core/verify.ts";
14 import type { Mail } from "../src/kernel/mail.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { seedAdmin } from "./helpers/admin.ts";
17
18 describe("MCP email_verify", () => {
19 let dataDir: string;
20 let app: App;
21 let servers: Servers;
22 let id = 1;
23 const sent: Mail[] = [];
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-mcp-verify-"));
27 app = createApp({
28 dataDir,
29 port: 0,
30 url: "https://forge.example",
31 mailer: async (mail) => {
32 sent.push(mail);
33 return true;
34 },
35 });
36 servers = await serve(app);
37 setLimit(app.db, "signup.per_source", 1000, "setup");
38 const owner = await seedAdmin(app, { username: "olive", email: "[email protected]", password: "owner-passphrase" });
39 if (!owner.ok) throw new Error(owner.error);
40 });
41 afterAll(async () => {
42 servers.stop();
43 app.close();
44 await rm(dataDir, { recursive: true, force: true });
45 });
46
47 const join_ = async (name: string) => {
48 const res = await fetch(`${servers.url}/api/v1/users`, {
49 method: "POST",
50 headers: { "content-type": "application/json" },
51 body: JSON.stringify({ username: name, email: `${name}@outsider.test`, password: "outsider-passphrase" }),
52 });
53 expect(res.status).toBe(201);
54 const user = getUserByName(app.db, name)!;
55 return { user, token: createToken(app.db, user.id, "t", "rw").token };
56 };
57
58 const ask = async (token: string) => {
59 const res = await fetch(`${servers.url}/mcp`, {
60 method: "POST",
61 headers: {
62 "content-type": "application/json",
63 accept: "application/json, text/event-stream",
64 authorization: `Bearer ${token}`,
65 },
66 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name: "email_verify", arguments: {} } }),
67 });
68 const body: any = await res.json();
69 const text: string = body.result.content[0].text;
70 return { isError: body.result.isError === true, text };
71 };
72
73 test("an unverified person asks once; asking again inside the limit is refused", async () => {
74 const { user, token } = await join_("ada");
75 app.db.query("UPDATE email_check SET last_sent_at = '2000-01-01T00:00:00.000Z' WHERE user_id = ?1").run(user.id);
76 const before = sent.length;
77 const first = await ask(token);
78 expect(first.isError).toBe(false);
79 expect(JSON.parse(first.text).outcome).toBe("sent");
80 expect(sent.length).toBe(before + 1);
81 expect(sent[before]!.to).toBe("[email protected]");
82 expect(first.text).not.toContain("/verify");
83 expect(first.text).not.toContain("token");
84 const again = await ask(token);
85 expect(again.isError).toBe(true);
86 expect(again.text).toContain("too_soon");
87 expect(sent.length).toBe(before + 1);
88 });
89
90 test("a verified person is told so, and nothing is sent", async () => {
91 const { user, token } = await join_("grace");
92 app.db.query("UPDATE email_check SET verified_at = '2020-01-01T00:00:00.000Z' WHERE user_id = ?1").run(user.id);
93 if (!emailVerified(app.db, user.id)) throw new Error("fixture: not verified");
94 const before = sent.length;
95 const res = await ask(token);
96 expect(res.isError).toBe(false);
97 expect(JSON.parse(res.text).outcome).toBe("already");
98 expect(sent.length).toBe(before);
99 });
100 });
test/mcp.test.ts +1
@@ -258,6 +258,7 @@ describe("the forge over MCP", () => {
258258 "cloud_account",
259259 "quotas",
260260 "signup",
261 "email_verify",
261262 "boards",
262263 "board_write",
263264 "board_pins",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs