Skip to content

Commit 7712ecb

Browse files
authored
Charge the server actions the same budget the tools pay (#11)
Rate limiting only ever wrapped MCP tool calls. Every one of those tools has a browser twin that calls the same service function and paid nothing, so the form was the cheap way in: thirty POSTs to `draftSiteAction` is thirty page fetches, thirty LLM calls and thirty VerifiedDR lookups against a monthly quota, where `submit_site` stops at twenty an hour. `markLinkPlacedAction` is worse in kind than in cost, since it crawls a URL the caller chose and an uncapped surface that fetches on demand is a request proxy with our user agent on it. The five actions now spend the budget of the tool they twin, under that tool's name and keyed on `member:<id>`, which is the key `callerKey` already builds for a signed-in caller. One allowance per member across both interfaces rather than one each. A submit costs two either way: the tool path passes through `guard` twice, once to draft and once to confirm. `lib/mcp/limits.ts` moves to `lib/limits.ts`, since it now serves both interfaces and the ESLint layering rule is about what MCP code may import, not about who may import it. No budget changed.
1 parent 3709aa5 commit 7712ecb

7 files changed

Lines changed: 72 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ next-app/src/
1515
endpoint, api/cron/* are the two scheduled jobs.
1616
lib/services/ The data-access seam. Everything goes through here.
1717
lib/db/ Drizzle schema and the per-request connection handle.
18-
lib/mcp/ Tool registration and rate-limit budgets. No logic of its own.
18+
lib/mcp/ Tool registration. No logic of its own.
19+
lib/limits.ts Per-tool budgets, spent by the tools and the server actions alike.
1920
lib/exchange.ts Pure domain rules, and enum values derived from the pgEnums.
2021
components/web/ All shared components, flat, kebab-case, named exports.
2122
emails/ React Email templates.

next-app/src/app/api/mcp/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server";
88

99
import { bearerFromHeader, resolveMemberFromBearer } from "@/lib/auth/api-key";
10-
import { callerKey } from "@/lib/mcp/limits";
10+
import { callerKey } from "@/lib/limits";
1111
import { type ToolContext, registerTools } from "@/lib/mcp/tools";
1212

1313
/**

next-app/src/app/app/actions.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use server";
22

3+
import { RateLimited, enforceToolLimit, memberCaller, rateLimitedMessage } from "@/lib/limits";
34
import { type LinkBrief, LinkError, type PlacementReport, getLinkBrief, markLinkPlaced } from "@/lib/services/links";
45
import { MatchError, respondToMatch } from "@/lib/services/matches";
56
import { getSessionMember } from "@/lib/session";
@@ -12,6 +13,12 @@ import { getSessionMember } from "@/lib/session";
1213
* call the same service functions the tools call, so the two interfaces cannot
1314
* drift: `respond_to_match` and this file both end up in `respondToMatch`.
1415
*
16+
* Each one spends the budget of the tool it twins, under that tool's name, so a
17+
* member has one allowance rather than one per interface. `mark_link_placed` is
18+
* the one that matters: it crawls a URL the caller chose, and an uncapped
19+
* surface that fetches on demand is a request proxy with our name on the
20+
* outbound packets.
21+
*
1522
* Conventions, matching `submit/actions.ts` and `app/key/actions.ts`: file-level
1623
* "use server", a discriminated union with an "idle" arm, the member fetched
1724
* first and a `signed_out` arm rather than a redirect, errors RETURNED not
@@ -40,9 +47,12 @@ export async function respondToMatchAction(_previous: RespondState, formData: Fo
4047
if (!matchId) return { status: "error", matchId, message: "No match was submitted." };
4148

4249
try {
50+
await enforceToolLimit("respond_to_match", memberCaller(member.id));
51+
4352
const view = await respondToMatch({ member, matchId, accept, reason: reason || undefined });
4453
return { status: "done", matchId, accepted: accept, revealed: view.revealed };
4554
} catch (err) {
55+
if (err instanceof RateLimited) return { status: "error", matchId, message: rateLimitedMessage(err) };
4656
if (err instanceof MatchError) return { status: "error", matchId, message: err.message };
4757
console.error("dashboard: respondToMatch failed", err);
4858
return { status: "error", matchId, message: "Could not record that. The error was logged." };
@@ -80,9 +90,12 @@ export async function getLinkBriefAction(_previous: BriefState, formData: FormDa
8090
if (!matchId) return { status: "error", matchId, message: "No match was submitted." };
8191

8292
try {
93+
await enforceToolLimit("get_link_brief", memberCaller(member.id));
94+
8395
const brief = await getLinkBrief({ member, matchId, format });
8496
return { status: "done", matchId, brief };
8597
} catch (err) {
98+
if (err instanceof RateLimited) return { status: "error", matchId, message: rateLimitedMessage(err) };
8699
if (err instanceof LinkError) return { status: "error", matchId, message: err.message };
87100
console.error("dashboard: getLinkBrief failed", err);
88101
return { status: "error", matchId, message: "Could not build that brief. The error was logged." };
@@ -126,6 +139,8 @@ export async function markLinkPlacedAction(_previous: PlaceState, formData: Form
126139
}
127140

128141
try {
142+
await enforceToolLimit("mark_link_placed", memberCaller(member.id));
143+
129144
const report = await markLinkPlaced({
130145
member,
131146
matchId,
@@ -134,6 +149,7 @@ export async function markLinkPlacedAction(_previous: PlaceState, formData: Form
134149
});
135150
return { status: "done", matchId, report };
136151
} catch (err) {
152+
if (err instanceof RateLimited) return { status: "error", matchId, message: rateLimitedMessage(err) };
137153
if (err instanceof LinkError) return { status: "error", matchId, message: err.message };
138154
console.error("dashboard: markLinkPlaced failed", err);
139155
return { status: "error", matchId, message: "Could not check that page. The error was logged." };

next-app/src/app/submit/actions.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { signDraft, verifyDraft } from "@/app/submit/draft-signature";
44
import type { Category } from "@/lib/categories";
55
import { AnalyzeError } from "@/lib/contracts";
6+
import { RateLimited, enforceToolLimit, memberCaller, rateLimitedMessage } from "@/lib/limits";
67
import { SiteError, commitSite, draftSite } from "@/lib/services/sites";
78
import { getSessionMember } from "@/lib/session";
89

@@ -74,6 +75,11 @@ export async function draftSiteAction(_previous: DraftState, formData: FormData)
7475
if (!member) return { status: "signed_out", url };
7576

7677
try {
78+
// Same bucket the `submit_site` tool spends, because this costs the same
79+
// money: a page fetch, an LLM call, and one of a monthly quota of
80+
// VerifiedDR lookups. Charged before the work, not after.
81+
await enforceToolLimit("submit_site", memberCaller(member.id));
82+
7783
const draft = await draftSite(url);
7884

7985
if (draft.alreadyListed) {
@@ -104,6 +110,7 @@ export async function draftSiteAction(_previous: DraftState, formData: FormData)
104110

105111
/** Mirrors `explain()` in `src/lib/mcp/tools.ts`, word for word where it applies. */
106112
function explainAnalyzeFailure(err: unknown): string {
113+
if (err instanceof RateLimited) return rateLimitedMessage(err);
107114
if (err instanceof AnalyzeError) {
108115
const hint =
109116
err.code === "too_thin"
@@ -213,6 +220,11 @@ export async function commitSiteAction(_previous: CommitState, formData: FormDat
213220
const placementOffered = String(formData.get("placementOffered") ?? "");
214221

215222
try {
223+
// Both halves of a submit are charged, exactly as they are on the tool
224+
// path, where the draft call and the confirm call each pass through
225+
// `guard`. One submitted listing costs two either way.
226+
await enforceToolLimit("submit_site", memberCaller(member.id));
227+
216228
const site = await commitSite({
217229
member,
218230
url: draft.url,
@@ -230,6 +242,9 @@ export async function commitSiteAction(_previous: CommitState, formData: FormDat
230242
outcome: PENDING_REVIEW_OUTCOME,
231243
};
232244
} catch (err) {
245+
if (err instanceof RateLimited) {
246+
return { status: "error", message: rateLimitedMessage(err), field: null };
247+
}
233248
if (err instanceof SiteError) {
234249
const field =
235250
err.code === "invalid_category" || err.code === "unmatchable_category"

next-app/src/lib/db/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,7 @@ export const rateLimits = pgTable(
336336
* Seconds, not milliseconds, because this is `int4`: epoch ms is about
337337
* 831 times larger than int4 can hold and would overflow on every
338338
* write. Lossless for any window of a second or more, which every
339-
* budget in `lib/mcp/limits.ts` is. Widen to `bigint` before adding a
339+
* budget in `lib/limits.ts` is. Widen to `bigint` before adding a
340340
* sub-second window.
341341
*/
342342
windowStart: integer("window_start").notNull(),
Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { enforceRateLimit } from "@/lib/rate-limit";
22

33
/**
4-
* @file Rate limiting for the MCP surface.
4+
* @file Rate limiting for both interfaces.
5+
*
6+
* It used to live in `lib/mcp/` and cap the tool surface only, which quietly
7+
* made the browser the cheap way in: a server action is a POST endpoint that
8+
* anyone signed in can call in a loop, and `draftSiteAction` spends a page
9+
* fetch, an LLM call and a VerifiedDR lookup per call exactly as `submit_site`
10+
* does. One capped path and one uncapped path to the same service function is
11+
* not a budget. The budgets are keyed by the TOOL NAME on purpose, so a member
12+
* shares one bucket across both interfaces rather than getting a second
13+
* allowance by switching surface.
514
*
615
* The read tools (`search_partners`, `get_categories`, `get_rules`) answer with
716
* no credentials at all. That is deliberate, it is what makes `claude mcp add`
@@ -65,7 +74,7 @@ export class RateLimited extends Error {
6574
* generously enough that ordinary use never notices them.
6675
*/
6776
export function callerKey(memberId: string | null, headers: Headers): string {
68-
if (memberId) return `member:${memberId}`;
77+
if (memberId) return memberCaller(memberId);
6978
// CF-Connecting-IP is set by Cloudflare's edge on every request it proxies
7079
// and any inbound copy is overwritten, so it cannot be spoofed by the
7180
// caller. x-forwarded-for is the fallback for anything not behind the edge
@@ -77,6 +86,31 @@ export function callerKey(memberId: string | null, headers: Headers): string {
7786
return `ip:${edge || forwarded || headers.get("x-real-ip") || "unknown"}`;
7887
}
7988

89+
/**
90+
* The bucket a signed-in member shares between the two interfaces.
91+
*
92+
* The browser knows who the caller is before it does anything, so it never
93+
* needs the IP fallback in {@link callerKey}. Same string either way, which is
94+
* the point: twenty submits from the tool and twenty from the form are forty
95+
* submits, not two allowances of twenty.
96+
*/
97+
export function memberCaller(memberId: string): string {
98+
return `member:${memberId}`;
99+
}
100+
101+
/**
102+
* What a browser surface tells someone who ran out of budget.
103+
*
104+
* Kept beside the budgets rather than written out at each call site, so the
105+
* five server actions cannot end up saying five different things about the same
106+
* refusal. The tool surface has its own wording in `explain()`, which is
107+
* addressed to a model and tells it to stop looping.
108+
*/
109+
export function rateLimitedMessage(err: RateLimited): string {
110+
const minutes = Math.max(1, Math.ceil(err.retryAfterSeconds / 60));
111+
return `You are doing that faster than the exchange allows. Wait about ${minutes} minute(s) and try again.`;
112+
}
113+
80114
/**
81115
* Enforces the budget for one tool call.
82116
*

next-app/src/lib/mcp/tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { CATEGORIES } from "@/lib/categories";
55
import { AnalyzeError } from "@/lib/contracts";
66
import type { ExchangeMember } from "@/lib/db/schema";
77
import { PLACEMENT_OFFERS } from "@/lib/exchange";
8-
import { RateLimited, enforceToolLimit } from "@/lib/mcp/limits";
8+
import { RateLimited, enforceToolLimit } from "@/lib/limits";
99
import { getCategoryDepths, getRules } from "@/lib/services/catalog";
1010
import { LinkError, checkLinks, getLinkBrief, getStanding, markLinkPlaced } from "@/lib/services/links";
1111
import { MatchError, listMatches, respondToMatch, searchPartners } from "@/lib/services/matches";

0 commit comments

Comments
 (0)