feat(builder): rate limit per identity instead of per process

The limiter was a module-level singleton, so one busy caller throttled every
other caller on the box and a shared daily budget was trivially exhausted.
Buckets are now keyed by authenticated identity, and keyed centrally so
reconnecting does not hand back a clean bucket.
This commit is contained in:
FilipBl4gojevic
2026-07-28 19:59:24 +02:00
parent 9153500629
commit dda81ad671
5 changed files with 54 additions and 27 deletions

View File

@@ -11,7 +11,7 @@ import {
} from '@apexfusion/vector-mcp-shared/config';
export { deriveNftAssetName };
import { safetyLayer } from './safety.js';
import { rateLimiter } from './rate-limiter.js';
import { limiterFor } from './rate-limiter.js';
// Registry constants - agent-registry v2 (audited, compliant)
// Source blueprint: vector-ai-agents/agent-registry/deploy/agent-registry/plutus.json
@@ -156,19 +156,21 @@ async function getRegistryAddress() {
return _registryAddress;
}
// ─── Rate limit wrapper ──────────────────────────────────────────────────────
function checkRateLimit() {
const rateCheck = rateLimiter.check();
if (!rateCheck.allowed) {
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
return null;
}
// ─── Tool Registration ───────────────────────────────────────────────────────
export function registerAgentNetworkTools(server) {
export function registerAgentNetworkTools(server, identity) {
const rateLimiter = limiterFor(identity);
// Rate limit wrapper - defined inside the register function (not at module
// scope) so this closes over the per-identity `rateLimiter` above rather
// than a module-wide global.
function checkRateLimit() {
const rateCheck = rateLimiter.check();
if (!rateCheck.allowed) {
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
return null;
}
// vector_get_agent_profile (read-only - no mnemonic needed)
server.tool(

View File

@@ -24,4 +24,23 @@ export class RateLimiter {
}
const RATE_LIMIT_PER_MINUTE = parseInt(process.env.VECTOR_RATE_LIMIT_PER_MINUTE || '60');
export const rateLimiter = new RateLimiter(RATE_LIMIT_PER_MINUTE);
// One bucket per identity. A process-global limiter meant one busy caller
// throttled everyone on the box; keying by identity fixes that. The registry
// is keyed rather than per-connection so a caller cannot reset its own budget
// by reconnecting.
const limiters = new Map<string, RateLimiter>();
export function limiterFor(identity: string): RateLimiter {
let limiter = limiters.get(identity);
if (!limiter) {
limiter = new RateLimiter(RATE_LIMIT_PER_MINUTE);
limiters.set(identity, limiter);
}
return limiter;
}
/** Test-only. Clears every bucket. */
export function resetLimiters(): void {
limiters.clear();
}

View File

@@ -23,7 +23,7 @@ import {
VECTOR_OGMIOS_URL, VECTOR_SUBMIT_URL, VECTOR_KOIOS_URL, VECTOR_EXPLORER_URL, explorerTxLink,
} from '@apexfusion/vector-mcp-shared/config';
import { safetyLayer } from './safety.js';
import { rateLimiter } from './rate-limiter.js';
import { limiterFor } from './rate-limiter.js';
// Agent Registry policy ID (shared across all modules)
const AGENT_REGISTRY_POLICY = process.env.AGENT_REGISTRY_POLICY || 'be1a0a2912da180757ed3cd61b56bb8eab0188c19dc3c0e3912d2c01';
@@ -85,14 +85,6 @@ function lovelaceToApex(lovelace: number | bigint): string {
return (Number(BigInt(String(lovelace))) / 1_000_000).toFixed(6);
}
function checkRateLimit() {
const rateCheck = rateLimiter.check();
if (!rateCheck.allowed) {
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
return null;
}
function scriptHashToAddress(hash: string): string {
return credentialToAddress('Mainnet', { type: 'Script', hash });
}
@@ -263,7 +255,20 @@ function parseEndorsementDatum(datumCbor: string): any | null {
// ─── Tool Registration ──────────────────────────────────────────────────────
export function registerSelfImprovementTools(server) {
export function registerSelfImprovementTools(server, identity) {
const rateLimiter = limiterFor(identity);
// Rate limit wrapper - defined inside the register function (not at module
// scope, where it used to live alongside the other helpers above) so this
// closes over the per-identity `rateLimiter` above rather than a
// module-wide global.
function checkRateLimit() {
const rateCheck = rateLimiter.check();
if (!rateCheck.allowed) {
return { content: [{ type: "text", text: `Rate limit exceeded. Retry after ${rateCheck.retryAfterMs}ms.` }] };
}
return null;
}
// ─── vector_self_improvement_browse (read-only) ───────────────────────────────

View File

@@ -13,7 +13,7 @@ import {
VECTOR_OGMIOS_URL, VECTOR_SUBMIT_URL, VECTOR_KOIOS_URL, VECTOR_EXPLORER_URL, explorerTxLink,
} from '@apexfusion/vector-mcp-shared/config';
import { safetyLayer } from './safety.js';
import { rateLimiter } from './rate-limiter.js';
import { limiterFor } from './rate-limiter.js';
import { registerAgentNetworkTools } from './agent-network.js';
import { registerSelfImprovementTools } from './self-improvement.js';
import type {
@@ -473,7 +473,8 @@ export async function interactWithContract(
}
// Register all Vector MCP tools
export function registerVectorTools(server: McpServer) {
export function registerVectorTools(server: McpServer, identity: string) {
const rateLimiter = limiterFor(identity);
// vector_get_balance - Get balance for any address
server.tool(
@@ -1286,8 +1287,8 @@ Funds ${actionVerb} the script address.
// Agent network tools (register, discover, message, profile) live in agent-network.ts
// to isolate C (Cardano WASM) imports from tsc's complex type inference
registerAgentNetworkTools(server);
registerAgentNetworkTools(server, identity);
// Self-Improvement Module tools (Module 6)
registerSelfImprovementTools(server);
registerSelfImprovementTools(server, identity);
}

Binary file not shown.