Budget
NAME
MCP::Server::Tool::Web::Budget - the deadline and byte ledger one tool call spends
SYNOPSIS
use MCP::Server::Tool::Web::Budget;
# One budget per tool call. The clock starts now, not at the first request.
my $budget = MCP::Server::Tool::Web::Budget.new(
total-seconds => 30,
total-bytes => 2 * 1024 * 1024,
max-redirects => 5,
);
say $budget.remaining-seconds; # 29.99...
say $budget.expired; # False
# The ledger: ask for what a chunk needs, get what is left to give.
say $budget.take(1000); # 1000 ā all of it
say $budget.spent-bytes; # 1000
say $budget.remaining-bytes; # 2096152
# A body bigger than the budget is cut, and the cut is remembered.
my $big = MCP::Server::Tool::Web::Budget.new(total-bytes => 10);
say $big.take(4); # 4
say $big.take(99); # 6 ā only six were left
say $big.truncated; # True
say $big.take(1); # 0 ā nothing left at all
A budget is shared, not copied: one crawl hands the same object to every page it fetches, so twenty pages cannot each spend the whole allowance.
my $shared = MCP::Server::Tool::Web::Budget.new(total-bytes => 100_000);
for @urls -> $url {
last if $shared.expired || !$shared.remaining-bytes;
my $page = $fetcher.fetch($url, budget => $shared);
say "{$url}: {$page.byte-count} bytes, {$shared.remaining-bytes} left";
}
DESCRIPTION
Three limits and a clock, in one object that everything on a single tool call shares. It is deliberately dumb: it has no idea what a socket is, holds no timer, starts no thread, and cannot fail. The Fetcher asks it questions and acts on the answers.
The clock starts at construction
total-seconds is measured from the moment the budget is made, not from the
first request ā connecting is part of what the caller is waiting for, and a
crawl that spends its whole allowance on DNS should say so rather than run
long. remaining-seconds never goes negative; it bottoms out at zero, which
is what expired reports.
The ledger cuts, it does not throw
take($n) answers with how many of the $n bytes the budget could afford ā
$n when there is room, less when there is not, zero when there is none.
That is the whole protocol: the caller appends what it was given, and if it
was given less than it asked for it knows the body was cut and can stop
reading. Nothing here throws, because a body that ran over a limit is a
result ("here is the first 2 MB of it"), not an error.
truncated latches True the first time take gives back less than it was
asked for. It is not set merely by the budget running out on a body that had
already ended: the last chunk of a body that fits exactly is taken whole, and
a take(0) asks for nothing and is given nothing.
Thread safety
The byte ledger is guarded by a lock. Body chunks arrive on the scheduler's threads rather than the caller's, and a crawl may one day fetch in parallel; an under-counted ledger would be a silent hole in the byte cap rather than a visible bug.