Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
node-version: "24.22.1"
cache: npm
cache-dependency-path: workers/mcp/package-lock.json
- run: npm ci
Expand Down
15 changes: 13 additions & 2 deletions docs/DEPLOY_WORKERS_MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ Mapeamento das tools de **dados** da Mais Retorno (cálculo/analytics fora):

```bash
cd workers/mcp
npm install
npx wrangler deploy
npm ci
npm run deploy
```

Custom domain (depois do smoke em `*.workers.dev`):
Expand Down Expand Up @@ -81,6 +81,17 @@ intervalo caem em `last_n≤200`. Rate limits de `/mcp` não mudam.
e corpo `{ "error": "rate_limited" }`. Landing `/` e `/health` ficam
fora do limite. Os contadores são por localização Cloudflare.

O build/test do Worker usa Node 24.22.1. O runtime publicado é Cloudflare
Workers (workerd); a versão de Node do build não comprova a correção do
runtime gerenciado.

Antes de analisar JSON, `/mcp` limita o corpo a 64 KiB, inclusive no protocolo
legado e em requisições sem `Content-Length`; excesso retorna HTTP 413.
Cada chamada de tool permite até 1000 elementos de entrada. O handler mantém
uma nova instância de servidor/transporte por requisição. A superfície é pública
e anônima: não recebe bearer tokens, portanto `expectedResource` não se aplica.
Qualquer futura autenticação deve validar a audience do recurso explicitamente.

## FastAPI interno (Tailscale)

No compose gVisor: sem labels Traefik; portas `127.0.0.1:8000` e
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ dependencies = [
"fastapi-mcp>=0.4",
# fastapi-mcp 0.4 calls mcp.Server(name, description); mcp 2.x dropped the
# description positional arg — cap below 2 until fastapi-mcp catches up.
"mcp>=1.12,<2",
"mcp>=1.30.0,<2",
"typer>=0.15",
"rich>=13.0",
"slowapi>=0.1.9",
Expand Down
866 changes: 209 additions & 657 deletions workers/mcp/package-lock.json

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions workers/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"types": "wrangler types",
"test": "node --test --experimental-strip-types src/rateLimit.test.ts src/tools/cvm.test.ts"
"test": "node --test --experimental-strip-types src/rateLimit.test.ts src/tools/cvm.test.ts src/security.test.ts"
},
"dependencies": {
"@cloudflare/workers-types": "^5.20260813.1",
"@modelcontextprotocol/server": "^2.0.0",
"@modelcontextprotocol/server": "2.3.1",
"agents": "^0.20.1",
"wrangler": "^4.122.0",
"zod": "^4.4.3"
},
"overrides": {
"@modelcontextprotocol/server": "2.3.1",
"@modelcontextprotocol/client": "2.3.1",
"@modelcontextprotocol/sdk": "1.32.1",
"sharp": "0.35.5"
}
}
16 changes: 15 additions & 1 deletion workers/mcp/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { readRequestBody } from "@modelcontextprotocol/server";
import { createMcpHandler } from "agents/mcp/server";
import { enforceMcpRateLimits } from "./rateLimit";
import { createServer } from "./server";

const mcp = createMcpHandler(createServer, { route: "/mcp" });
const MAX_REQUEST_BYTES = 64 * 1024;
const mcp = createMcpHandler(createServer, {
route: "/mcp",
maxRequestBodySize: MAX_REQUEST_BYTES,
});

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
Expand All @@ -24,6 +29,15 @@ export default {
if (limited) {
return limited;
}
// Agents' legacy compatibility path does not forward maxRequestBodySize.
// Bound the stream before either protocol path parses the JSON payload.
if (request.method === "POST") {
const body = await readRequestBody(request, MAX_REQUEST_BYTES);
if (body.tooLarge) {
return Response.json({ error: "payload_too_large" }, { status: 413 });
}
request = new Request(request, { body: body.text });
}
return mcp(request, env, ctx);
}
return env.ASSETS.fetch(request);
Expand Down
64 changes: 64 additions & 0 deletions workers/mcp/src/security.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { unstable_startWorker } from "wrangler";

let worker: Awaited<ReturnType<typeof unstable_startWorker>>;
before(async () => {
worker = await unstable_startWorker({
config: "wrangler.toml",
dev: { remote: false, persist: false, server: { hostname: "127.0.0.1", port: 0 } },
});
await worker.ready;
});
after(async () => { await worker?.dispose(); });

async function post(method: string, params: unknown, protocol = "2025-11-25") {
return worker.fetch("http://localhost/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"MCP-Protocol-Version": protocol,
"cf-connecting-ip": "198.51.100.10",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
signal: AbortSignal.timeout(15000),
});
}
async function result(response: Response) {
assert.equal(response.status, 200);
const text = await response.text();
const data = text.split("\n").find((line) => line.startsWith("data: "));
assert.ok(data, text);
return JSON.parse(data.slice(6));
}

test("concurrent requests with the same RPC ID retain independent transports", async () => {
const [catalog, ping] = await Promise.all([
post("tools/list", {}).then(result),
post("ping", {}).then(result),
]);
assert.equal(catalog.id, 1);
assert.ok(catalog.result.tools.some((tool: { name: string }) => tool.name === "bcb_series"));
assert.equal(ping.id, 1);
assert.deepEqual(ping.result, {});
});

test("both protocol lanes reject oversized bodies without Content-Length", async () => {
for (const protocol of ["2025-11-25", "2026-07-28"]) {
const response = await post("tools/list", { padding: "a".repeat(64 * 1024) }, protocol);
assert.equal(response.status, 413);
assert.deepEqual(await response.json(), { error: "payload_too_large" });
}
});

test("tool element budget rejects excess input while the public catalog remains usable", async () => {
const rejected = await result(await post("tools/call", {
name: "bcb_series", arguments: { padding: Array(1001).fill(0) },
}));
assert.equal(rejected.result.isError, true);
assert.match(rejected.result.content[0].text, /maximum.*1000.*elements/);
const accepted = await result(await post("tools/call", { name: "bcb_series", arguments: {} }));
assert.notEqual(accepted.result.isError, true);
assert.ok(accepted.result.content.length > 0);
});
2 changes: 1 addition & 1 deletion workers/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function createServer() {
name: "openfindata",
version: "0.3.3",
websiteUrl: "https://openfindata.com.br",
});
}, { maxToolInputElements: 1000 });

server.registerTool(
"bcb_series",
Expand Down
Loading