Skip to content

Commit 042fd68

Browse files
committed
mcp(fix[batch_tools]): Limit generic batch operation count
why: A batch with enough small row results can exceed the response cap even after nested payload truncation, because row metadata alone still serializes into the FastMCP response envelope. what: - Reject generic tool batches above a fixed operation-count cap - Add a public FastMCP regression for row-only oversized batch responses
1 parent ce9b85b commit 042fd68

2 files changed

Lines changed: 66 additions & 0 deletions

File tree

src/libtmux_mcp/tools/batch_tools.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
}
4545
)
4646

47+
MAX_BATCH_OPERATIONS = 1_000
48+
4749
_BATCH_TRUNCATED_CONTENT: list[dict[str, t.Any]] = [
4850
{
4951
"type": "text",
@@ -246,6 +248,9 @@ async def _call_tools_batch(
246248
if not operations:
247249
msg = "operations must contain at least one tool call"
248250
raise ExpectedToolError(msg)
251+
if len(operations) > MAX_BATCH_OPERATIONS:
252+
msg = f"operations must contain at most {MAX_BATCH_OPERATIONS} tool calls"
253+
raise ExpectedToolError(msg)
249254
if on_error not in {"stop", "continue"}:
250255
msg = "on_error must be 'stop' or 'continue'"
251256
raise ExpectedToolError(msg)

tests/test_batch_tools.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,21 @@ class BatchResponseLimitFixture(t.NamedTuple):
3636
]
3737

3838

39+
class BatchOperationLimitFixture(t.NamedTuple):
40+
"""Test fixture for operation-count batch limiting."""
41+
42+
test_id: str
43+
operation_count: int
44+
45+
46+
BATCH_OPERATION_LIMIT_FIXTURES: list[BatchOperationLimitFixture] = [
47+
BatchOperationLimitFixture(
48+
test_id="many_missing_tools",
49+
operation_count=6_000,
50+
),
51+
]
52+
53+
3954
class BatchAnnotationFixture(t.NamedTuple):
4055
"""Test fixture for generic batch wrapper annotations."""
4156

@@ -251,6 +266,52 @@ async def _call() -> t.Any:
251266
]
252267

253268

269+
@pytest.mark.parametrize(
270+
BatchOperationLimitFixture._fields,
271+
BATCH_OPERATION_LIMIT_FIXTURES,
272+
ids=[fixture.test_id for fixture in BATCH_OPERATION_LIMIT_FIXTURES],
273+
)
274+
def test_call_readonly_tools_batch_rejects_oversized_operation_count(
275+
test_id: str,
276+
operation_count: int,
277+
) -> None:
278+
"""The batch wrapper rejects requests whose rows alone can exceed the cap."""
279+
from fastmcp import Client
280+
281+
from libtmux_mcp.middleware import DEFAULT_RESPONSE_LIMIT_BYTES
282+
283+
assert test_id
284+
285+
async def _call() -> t.Any:
286+
async with Client(_batch_probe_server()) as client:
287+
return await client.call_tool(
288+
"call_readonly_tools_batch",
289+
{
290+
"operations": [
291+
{
292+
"tool": "missing_probe",
293+
"arguments": {},
294+
}
295+
for _ in range(operation_count)
296+
],
297+
"on_error": "continue",
298+
},
299+
raise_on_error=False,
300+
)
301+
302+
result = asyncio.run(_call())
303+
serialized = json.dumps(
304+
_call_tool_result_wire(result),
305+
separators=(",", ":"),
306+
sort_keys=True,
307+
)
308+
309+
assert len(serialized.encode("utf-8")) <= DEFAULT_RESPONSE_LIMIT_BYTES
310+
assert result.is_error is True
311+
assert result.structured_content is None
312+
assert "operations must contain at most" in serialized
313+
314+
254315
def test_call_readonly_tools_batch_rejects_mutating_inner_tool() -> None:
255316
"""Readonly batching does not tunnel a mutating tool call."""
256317
from fastmcp import Client

0 commit comments

Comments
 (0)