feat(mcp): cap MCP batch tool at MAX_BATCH_OPERATIONS (PRJ-22)

- enforce the same 100-operation cap the HTTP /batch endpoint enforces;
  over-cap \x60calls\x60 reject with a structured INVALID_INPUT (retryable:
  false, matching CallError::invalid_input) before any dispatch
- hoist MAX_BATCH_OPERATIONS to gateway/mod.rs and reuse it in routes,
  to_openapi (removing a pre-existing duplicate literal), and to_mcp
- state the limit in the batch tool description and add maxItems to the
  input schema (doc previously advertised no limit)
- GatewayDispatch gains a per-instance invoke_count spy accessor so the
  over-cap test proves zero dispatches (process-global counters raced
  under the parallel test runner)
- tests: over-cap -> INVALID_INPUT + invoke_count()==0; at-cap -> 100
  results + invoke_count()==100

verification: scripts/verify.sh (352 passed) and scripts/verify.sh
--all-features (468 passed); cargo clippy --all-targets -D warnings and
cargo fmt --check clean
This commit is contained in:
2026-08-30 22:51:06 +00:00
parent 8261fefd8f
commit 4f644a4c2c
5 changed files with 101 additions and 6 deletions
+12
View File
@@ -30,6 +30,7 @@
//! fixed window).
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -50,6 +51,7 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
pub struct GatewayDispatch {
registry: Arc<OperationRegistry>,
identity_provider: Arc<dyn IdentityProvider>,
invoke_count: AtomicUsize,
}
impl GatewayDispatch {
@@ -62,6 +64,7 @@ impl GatewayDispatch {
Self {
registry,
identity_provider,
invoke_count: AtomicUsize::new(0),
}
}
@@ -75,6 +78,14 @@ impl GatewayDispatch {
&self.identity_provider
}
/// How many [`GatewayDispatch::invoke`] calls this spine has
/// served. A test-spy accessor: the over-cap batch tests assert it
/// stays at zero to prove no dispatch happened before the cap
/// rejection (review-002 PRJ-22).
pub fn invoke_count(&self) -> usize {
self.invoke_count.load(Ordering::Relaxed)
}
/// Resolve a bearer token to an identity (the auth-middleware hook).
pub fn resolve_bearer(&self, token: &AuthToken) -> Option<Identity> {
self.identity_provider.resolve_from_token(token)
@@ -88,6 +99,7 @@ impl GatewayDispatch {
op: &str,
input: Value,
) -> ResponseEnvelope {
self.invoke_count.fetch_add(1, Ordering::Relaxed);
let operation_name = strip_leading_slash(op).to_string();
let request_id = uuid::Uuid::new_v4().to_string();
let context = self.build_root_context(&request_id, &operation_name, identity);