Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion src/BraveAPI/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import type { Endpoints } from './types.js';
import config from '../config.js';
import { stringify } from '../utils.js';
import { describeAccessFailure, ENDPOINT_PLANS, isAccessFailure, type PlanId } from '../plans.js';

/**
* Error thrown when the Brave Search API returns a non-2xx response. Carries
* the HTTP status so callers can distinguish transient failures (429, 5xx)
* from deterministic ones (401, 403, 422) that will not change on retry.
*/
export class BraveApiError extends Error {
readonly status: number;
readonly endpoint: keyof Endpoints;
readonly requiredPlan: PlanId;

constructor(status: number, endpoint: keyof Endpoints, message: string) {
super(message);
this.name = 'BraveApiError';
this.status = status;
this.endpoint = endpoint;
this.requiredPlan = ENDPOINT_PLANS[endpoint];
}
}

const typeToPathMap: Record<keyof Endpoints, string> = {
images: '/res/v1/images/search',
Expand Down Expand Up @@ -127,8 +147,14 @@ async function issueRequest<T extends keyof Endpoints>(
errorMessage += `\n${await response.text()}`;
}

// A 401/403/422 is usually a plan mismatch rather than a malformed key.
// Say which plan this endpoint needs, so the caller can act on it.
if (isAccessFailure(response.status)) {
errorMessage += `\n\n${describeAccessFailure(endpoint)}`;
}

// TODO (Sampson): Setup proper error handling, updating state, etc.
throw new Error(errorMessage);
throw new BraveApiError(response.status, endpoint, errorMessage);
}

// Return Response
Expand Down
23 changes: 23 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,26 @@ export const RATE_LIMIT = {
perSecond: 1,
perMonth: 15000,
} as const;

/**
* Limits applied at the HTTP edge, before a request reaches the MCP SDK.
*
* `maxBatchSize` bounds how many tool calls a single HTTP request can dispatch.
* `maxBodySize` is an explicit parser cap rather than an inherited default, so
* the bound is a stated decision instead of a side effect of body-parser's
* 100kb default.
*/
export const HTTP_LIMITS = {
maxBatchSize: 10,
maxBodySize: '64kb',
} as const;

/**
* Polling behavior for the (async) Summarizer endpoint. `pollIntervalMs` is the
* delay between attempts; `pollAttempts` is the hard ceiling on outbound
* requests per tool call.
*/
export const SUMMARIZER_POLL = {
pollIntervalMs: 50,
pollAttempts: 20,
} as const;
84 changes: 84 additions & 0 deletions src/plans.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { Endpoints } from './BraveAPI/types.js';
import tools from './tools/index.js';
import {
describeAccessFailure,
describePlanRequirement,
ENDPOINT_PLANS,
isAccessFailure,
PLANS,
PLAN_DASHBOARD_URL,
} from './plans.js';

describe('plan registry', () => {
it('assigns every endpoint a plan that exists', () => {
for (const [endpoint, planId] of Object.entries(ENDPOINT_PLANS)) {
assert.ok(PLANS[planId], `${endpoint} maps to unknown plan '${planId}'`);
}
});

it('covers every endpoint the API client can reach', () => {
// Mirrors the keys of Endpoints; a new endpoint without a plan is a bug.
const endpoints: (keyof Endpoints)[] = [
'images',
'localPois',
'localDescriptions',
'news',
'videos',
'web',
'summarizer',
'llmContext',
'placeSearch',
];

for (const endpoint of endpoints) {
assert.ok(ENDPOINT_PLANS[endpoint], `${endpoint} has no plan assigned`);
}
});

it('separates search endpoints from the summarizer', () => {
assert.notEqual(ENDPOINT_PLANS.web, ENDPOINT_PLANS.summarizer);
});
});

describe('access failure classification', () => {
it('treats entitlement failures as access failures', () => {
assert.equal(isAccessFailure(401), true);
assert.equal(isAccessFailure(403), true);
assert.equal(isAccessFailure(422), true);
});

it('leaves throttling and upstream failures alone', () => {
assert.equal(isAccessFailure(429), false);
assert.equal(isAccessFailure(500), false);
assert.equal(isAccessFailure(200), false);
});
});

describe('plan guidance text', () => {
it('names the plan the endpoint needs', () => {
assert.match(describePlanRequirement('summarizer'), /Answers/);
assert.match(describePlanRequirement('web'), /Search/);
});

it('tells the caller retrying will not help, and where to go', () => {
const message = describeAccessFailure('summarizer');

assert.match(message, /Answers/);
assert.match(message, /retrying will not help/);
assert.ok(message.includes(PLAN_DASHBOARD_URL));
});
});

describe('tool descriptions', () => {
it('state a plan requirement on every registered tool', () => {
for (const tool of Object.values(tools)) {
assert.match(
tool.description,
/Requires the Brave Search '.+' plan\./,
`${tool.name} does not state its plan requirement`
);
}
});
});
76 changes: 76 additions & 0 deletions src/plans.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { Endpoints } from './BraveAPI/types.js';

export const PLAN_DASHBOARD_URL =
'https://api-dashboard.search.brave.com/app/subscriptions/subscribe';

export type PlanId = 'search' | 'searchPro' | 'answers';

/**
* Brave sells access as plans, and a subscription token is scoped to the plan
* it was issued under. A token that works for /web/search is not guaranteed to
* work for /summarizer/search. Naming follows the plan banners in
* brave/brave-search-skills.
*/
export const PLANS: Record<PlanId, { label: string; note?: string }> = {
search: {
label: 'Search',
},
searchPro: {
label: 'Search (Pro tier)',
note: 'Local and place endpoints are available only on the Pro tiers.',
},
answers: {
label: 'Answers',
note: "Referred to as 'Pro AI' elsewhere in the Brave docs.",
},
};

/**
* Which plan each endpoint is served under. This is the single place that
* knowledge lives; tool descriptions and error messages both read from here so
* they cannot drift apart.
*/
export const ENDPOINT_PLANS: Record<keyof Endpoints, PlanId> = {
web: 'search',
images: 'search',
videos: 'search',
news: 'search',
llmContext: 'search',
localPois: 'searchPro',
localDescriptions: 'searchPro',
placeSearch: 'searchPro',
summarizer: 'answers',
};

/**
* A one-line statement of what a given endpoint needs, suitable for appending
* to an error or embedding in a tool description.
*/
export const describePlanRequirement = (endpoint: keyof Endpoints): string => {
const plan = PLANS[ENDPOINT_PLANS[endpoint]];
const note = plan.note ? ` ${plan.note}` : '';
return `Requires the Brave Search '${plan.label}' plan.${note}`;
};

/**
* Guidance appended to authentication and authorization failures.
*
* A 401/403/422 from Brave is most often a plan mismatch rather than a
* malformed key: the token is valid, but it was issued under a plan that does
* not include this endpoint. Saying so turns an opaque failure into one the
* caller -- increasingly a model rather than a person -- can act on.
*/
export const describeAccessFailure = (endpoint: keyof Endpoints): string =>
[
describePlanRequirement(endpoint),
'If your API key is subscribed to a different plan, this request will fail on every attempt; retrying will not help.',
`Review or change your plan at ${PLAN_DASHBOARD_URL}`,
].join(' ');

/**
* Statuses that indicate the key is valid JSON but not entitled to this
* endpoint (or not valid at all). Distinct from 429 and 5xx, which are worth
* retrying.
*/
export const isAccessFailure = (status: number): boolean =>
status === 401 || status === 403 || status === 422;
103 changes: 103 additions & 0 deletions src/protocols/batch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { after, before, describe, it } from 'node:test';
import { HTTP_LIMITS } from '../constants.js';
import httpServer from './http.js';

const message = (id: number) => ({
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name: 'brave_summarizer', arguments: { key: 'x' } },
});

// Minimal well-formed messages, so a large batch stays under the body limit and
// is stopped by the batch cap rather than by the parser.
const minimal = (id: number) => ({ jsonrpc: '2.0', id, method: 'tools/call' });

const batch = (size: number, build: (id: number) => Record<string, unknown> = message) =>
Array.from({ length: size }, (_, i) => build(i));

describe('http JSON-RPC batch limits', () => {
let server: Server;
let baseUrl: string;

const post = (body: unknown) =>
fetch(`${baseUrl}/mcp`, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
},
body: JSON.stringify(body),
});

before(async () => {
const app = httpServer.createApp();
server = app.listen(0);
await new Promise<void>((resolve) => server.once('listening', resolve));
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});

after(async () => {
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve()))
);
});

it('rejects a batch larger than the limit with 400 and a JSON-RPC error', async () => {
const res = await post(batch(HTTP_LIMITS.maxBatchSize + 1));
const body = await res.json();

assert.equal(res.status, 400);
assert.equal(body.jsonrpc, '2.0');
assert.equal(body.id, null);
assert.equal(body.error.code, -32600);
});

it('rejects the 931-message batch from the amplification report', async () => {
const body = batch(931, minimal);
// Confirm the batch cap is doing the work here, not the parser limit.
assert.ok(Buffer.byteLength(JSON.stringify(body)) < 64 * 1024);

const res = await post(body);
const json = await res.json();

assert.equal(res.status, 400);
assert.match(json.error.message, /Batch size exceeds/);
});

it('passes a batch at exactly the limit through to the MCP SDK', async () => {
const res = await post(batch(HTTP_LIMITS.maxBatchSize));
const json = await res.json();

// The SDK rejects a session-less tools/call on its own terms; what matters
// is that the rejection is not ours.
assert.doesNotMatch(JSON.stringify(json), /Batch size exceeds/);
});

it('leaves single (non-array) messages untouched', async () => {
const res = await post({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} });

assert.notEqual(res.status, 400);
});

it('rejects a body over the configured parser limit with 413', async () => {
const res = await fetch(`${baseUrl}/mcp`, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'x',
params: { q: 'a'.repeat(70_000) },
}),
});

assert.equal(res.status, 413);
});
});
30 changes: 30 additions & 0 deletions src/protocols/batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';

// 400 with a JSON-RPC error and no id, per the MCP Streamable HTTP spec.
const sendBadRequest = (res: Response, message: string): void => {
res.status(400).json({ jsonrpc: '2.0', error: { code: -32600, message }, id: null });
};

/**
* Builds Express middleware that caps the number of messages in a JSON-RPC
* batch before the request reaches the MCP SDK.
*
* The SDK accepts batch arrays of any length, and every message in a batch can
* dispatch its own tool call, so one accepted HTTP request can fan out into an
* unbounded number of outbound Brave Search API calls. Capping the array length
* bounds that fan-out at the edge, independent of body size.
*
* Non-array bodies (the common single-message case) pass straight through.
*/
export const createBatchLimitGuard = (options: { maxBatchSize: number }): RequestHandler => {
const { maxBatchSize } = options;

return (req: Request, res: Response, next: NextFunction) => {
if (Array.isArray(req.body) && req.body.length > maxBatchSize) {
sendBadRequest(res, `Batch size exceeds the limit of ${maxBatchSize} messages`);
return;
}

next();
};
};
6 changes: 5 additions & 1 deletion src/protocols/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import createMcpServer from '../server.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { ListToolsRequest, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { createDnsRebindingGuard } from './rebinding.js';
import { createBatchLimitGuard } from './batch.js';
import { HTTP_LIMITS } from '../constants.js';

const yieldGenericServerError = (res: Response) => {
res.status(500).json({
Expand Down Expand Up @@ -71,7 +73,9 @@ const createApp = () => {
})
);

app.use('/mcp', express.json());
app.use('/mcp', express.json({ limit: HTTP_LIMITS.maxBodySize }));

app.use('/mcp', createBatchLimitGuard({ maxBatchSize: HTTP_LIMITS.maxBatchSize }));

app.all('/mcp', async (req: Request, res: Response) => {
try {
Expand Down
Loading