mcp-server-secure-wayleave
v0.0.1
Published
Stop your MCP server being drained by a runaway agent. Per-tool rate limits, concurrency caps, spend ceilings and repeat-loop detection, in one line. Zero dependencies.
Maintainers
Readme
mcp-server-secure-wayleave
Stop your MCP server being drained by a runaway agent.
npm install mcp-server-secure-wayleave
# or, the same package under the Wayleave scope:
npm install @wayleave/mcpBoth names are published from one source at the same version and are byte-identical. Install one of them — two copies would keep two sets of counters, and neither would see the other's calls.
import { McpGuard } from 'mcp-server-secure-wayleave';
const guard = new McpGuard({ dailyBudgetUsd: 5 });
await server.connect(guard.protect(transport)); // ← the only line you addZero dependencies. Works on stdio and Streamable HTTP. Nothing to sign up for.
The problem this exists for
You publish an MCP server. A tool in it calls a paid API, or an LLM, or a database that is not free to scan. An agent connects, decides the answer it got is not the answer it wanted, and calls the same tool again. And again.
Nothing here is malicious and no individual call is unreasonable. Each one is well under any per-minute limit you would think to set. The loop runs for four hours while you are asleep and the bill is waiting when you wake up.
That is the failure this package prevents. Not a DDoS — a polite agent stuck in a loop, spending your money one perfectly ordinary call at a time.
What it does
| | | |---|---| | Rate limits | Per tool, per client, on a sliding window | | Concurrency cap | How many tool calls may run at once | | Spend ceiling | Declare what a tool costs; set a hard limit it cannot cross | | Loop detection | Notices the same call repeated with identical arguments | | Argument size | Refuses payloads past a limit before anything else runs | | Block list | Turn a tool off without redeploying it |
A refused call never reaches your handler. That is the point, and it is what the integration tests check against a real MCP client: a limiter that returns a tidy error while the expensive handler runs anyway has protected nothing.
Configure it
Everything is optional. The defaults are chosen to be invisible to a working server and fatal to a loop.
const guard = new McpGuard({
// Default for every tool: 60 calls/minute per client
perMinute: 60,
perTool: {
search: { perMinute: 30 },
summarize: { perMinute: 10, perHour: 100, costUsd: 0.002 },
reindex: { perMinute: 1, maxConcurrent: 1 },
},
// Hard ceiling on declared costs, over a rolling 24h window.
// Tools with no costUsd keep working after it is reached.
dailyBudgetUsd: 5,
maxConcurrent: 8,
maxArgBytes: 256 * 1024,
// 6 identical calls in a minute looks like a loop.
// 'warn' (default) lets it through and flags it; 'deny' stops it.
repeat: { identicalCalls: 6, windowMs: 60_000, action: 'warn' },
block: ['dangerous_tool'],
onDecision: (d) => {
if (!d.allowed) console.warn(`[guard] refused ${d.tool}: ${d.reason}`);
if (d.warning) console.warn(`[guard] ${d.repeatWarning}`);
},
});Why a refusal is a tool result, not a protocol error
A JSON-RPC error tends to be swallowed by the client's transport and retried,
which makes a loop go faster. A result marked isError reaches the model as
text it can read:
This exact call has been made 6 times in the last 60 seconds. The result will not change — stop calling "search" with these arguments and try a different approach.
Breaking the loop at the model works better than refusing it at the wire.
Why identical arguments
A rate limit answers how fast. It cannot answer is this going anywhere. An
agent making progress varies its input; a stuck one does not. So the detector
fingerprints (tool, arguments) with sorted keys — argument order cannot
disguise a repeat — and counts how often that exact call recurs.
An agent paging through results with {page: 1}, {page: 2}, {page: 3} is
never touched by it, at any speed.
Using it without the transport wrapper
If you have written your own server, call the guard directly:
const decision = guard.check({ tool: name, args, clientId: session });
if (!decision.allowed) return decision.toolResult;
try {
return await runTool(name, args);
} finally {
decision.release?.(); // only needed if you passed a requestKey
}Concurrency is only counted when you pass a requestKey, because a slot has to
be given back and only a caller holding an identifier can give it back.
protect() supplies one for every request it sees.
What it does not do
Being clear about this is more useful than a longer feature list.
- No bot detection. Wayleave's HTTP gate sorts web traffic into four lanes by checking RFC 9421 signatures and bot directories. That model does not transfer to MCP: over stdio there is no HTTP request to inspect, and MCP clients — Claude Desktop, Cursor, an SDK script — do not carry Web Bot Auth signatures. A "verified agent" lane would be permanently empty. There are also no humans on an MCP server, so "humans always free" has nothing to protect.
- No authentication. If your server is remote, use MCP's own OAuth. This runs after that and cares only about what a client does, not who it is.
- Counts are per process. Everything is in memory. Run three instances and each keeps its own counters. For one server — what most people run — the numbers are exact.
costUsdis what you declare. It does not measure anything. If you say a tool costs $0.002 and it costs $0.02, the ceiling is ten times higher than you think.
Identity
clientId defaults to transport.sessionId when the transport has one
(Streamable HTTP gives each session its own), and to 'stdio' otherwise —
because a stdio server has exactly one client by definition. Override it if you
have something better, such as an authenticated subject:
await server.connect(guard.protect(transport, { clientId: user.id }));Wayleave
Wayleave is how APIs charge AI agents for access — signature-verified identity, HTTP 402, and metering, at wayleave.dev. This package is the MCP piece of it and works entirely on its own: no account, no key, no calls home.
MIT.
