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
14 changes: 14 additions & 0 deletions crates/opentake-agent/src/mcp/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5614,4 +5614,18 @@ mod tests {
);
assert!(r.is_error);
}

/// Composite acceptance entry tracked by the data-safety implementation plan.
/// Keep this as an executable roll-up of the owning MCP boundary tests so the
/// audit command proves validation, mutation, undo, and bridge fail-closed
/// behavior together rather than merely matching a test name.
#[test]
fn cross_cutting_mcp_acceptance() {
precise_path_arg_error_mentions_field();
add_clips_then_get_timeline_reflects_clip();
add_captions_is_one_undo_step();
undo_with_empty_stack_errors();
import_media_bytes_rejects_oversized_base64_before_bridge();
import_media_rejects_unknown_nested_source_key();
}
}
39 changes: 39 additions & 0 deletions crates/opentake-agent/src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ use crate::mcp::media_bridge::{MediaBridge, MCP_REQUEST_BODY_MAX};
use crate::plugin::registry::PluginRegistry;
use crate::prompt::assemble::assemble_system_prompt;
use crate::tools::descriptions::{description, input_schema};
use crate::tools::errors::first_non_finite_json_number_path;
use crate::tools::names::ToolName;
use crate::tools::panic_boundary::with_redacted_dispatch_panic;

Expand Down Expand Up @@ -368,6 +369,43 @@ async fn content_type_guard(
next.run(request).await
}

/// Buffer the already bounded MCP request once so non-standard JSON numeric
/// tokens and exponent overflow can be rejected with the tool-relative path
/// before rmcp's JSON decoder loses that context.
async fn finite_number_guard(
request: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
use axum::response::IntoResponse;

if request.method() != axum::http::Method::POST || request.uri().path() != "/mcp" {
return next.run(request).await;
}
let (parts, body) = request.into_parts();
let bytes = match axum::body::to_bytes(body, MCP_REQUEST_BODY_MAX).await {
Ok(bytes) => bytes,
Err(_) => {
return (
axum::http::StatusCode::PAYLOAD_TOO_LARGE,
"OpenTake MCP request body is too large",
)
.into_response();
}
};
if let Some(path) = first_non_finite_json_number_path(&bytes) {
return (
axum::http::StatusCode::BAD_REQUEST,
format!("{path}: value must be finite"),
)
.into_response();
}
next.run(axum::http::Request::from_parts(
parts,
axum::body::Body::from(bytes),
))
.await
}

/// Minimal OAuth protected-resource metadata: the server requires no auth (it is
/// loopback-only), so it advertises no authorization servers.
async fn oauth_protected_resource() -> axum::Json<Value> {
Expand Down Expand Up @@ -444,6 +482,7 @@ pub fn build_router_with_bridge_for_port(
axum::routing::get(oauth_protected_resource),
)
.route_service("/mcp", service)
.layer(axum::middleware::from_fn(finite_number_guard))
.layer(axum::middleware::from_fn(content_type_guard))
.layer(axum::middleware::from_fn(protocol_version_guard))
.layer(axum::middleware::from_fn_with_state(
Expand Down
284 changes: 281 additions & 3 deletions crates/opentake-agent/src/tools/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,264 @@ pub fn first_non_finite_number_path(value: &Value, path: &str) -> Option<String>
.iter()
.enumerate()
.find_map(|(i, v)| first_non_finite_number_path(v, &format!("{path}[{i}]"))),
Value::Object(map) => map
.iter()
.find_map(|(k, v)| first_non_finite_number_path(v, &format!("{path}.{k}"))),
Value::Object(map) => map.iter().find_map(|(k, v)| {
let child = if path.is_empty() {
k.clone()
} else {
format!("{path}.{k}")
};
first_non_finite_number_path(v, &child)
}),
_ => None,
}
}

/// Inspect bounded raw JSON before `serde_json`/rmcp decoding so JSON's
/// non-standard `NaN`/`Infinity` tokens and finite-syntax overflow numbers can
/// still receive the same path-precise tool error as in-process values.
pub fn first_non_finite_json_number_path(input: &[u8]) -> Option<String> {
RawNumberPathScanner::new(input)
.scan_value("", 0)
.map(argument_relative_path)
}

const RAW_NUMBER_MAX_DEPTH: usize = 128;
const RAW_NUMBER_MAX_PATH: usize = 256;

struct RawNumberPathScanner<'a> {
input: &'a [u8],
cursor: usize,
}

impl<'a> RawNumberPathScanner<'a> {
fn new(input: &'a [u8]) -> Self {
Self { input, cursor: 0 }
}

fn scan_value(&mut self, path: &str, depth: usize) -> Option<String> {
if depth > RAW_NUMBER_MAX_DEPTH {
return None;
}
self.skip_whitespace();
match self.input.get(self.cursor).copied()? {
b'{' => self.scan_object(path, depth),
b'[' => self.scan_array(path, depth),
b'"' => {
self.scan_string()?;
None
}
b'-' if self.consume_word(b"-Infinity") => Some(path.to_string()),
b'N' if self.consume_word(b"NaN") => Some(path.to_string()),
b'I' if self.consume_word(b"Infinity") => Some(path.to_string()),
b'-' | b'0'..=b'9' => self.scan_number(path),
b't' => {
self.consume_word(b"true");
None
}
b'f' => {
self.consume_word(b"false");
None
}
b'n' => {
self.consume_word(b"null");
None
}
_ => {
self.cursor += 1;
None
}
}
}

fn scan_object(&mut self, path: &str, depth: usize) -> Option<String> {
self.cursor += 1;
loop {
self.skip_whitespace();
if self.input.get(self.cursor) == Some(&b'}') {
self.cursor += 1;
return None;
}
let key = self.scan_string()?;
self.skip_whitespace();
if self.input.get(self.cursor) != Some(&b':') {
return None;
}
self.cursor += 1;
let child_path = bounded_object_path(path, &key);
if let Some(found) = self.scan_value(&child_path, depth + 1) {
return Some(found);
}
self.skip_whitespace();
match self.input.get(self.cursor) {
Some(b',') => self.cursor += 1,
Some(b'}') => {
self.cursor += 1;
return None;
}
_ => return None,
}
}
}

fn scan_array(&mut self, path: &str, depth: usize) -> Option<String> {
self.cursor += 1;
let mut index = 0;
loop {
self.skip_whitespace();
if self.input.get(self.cursor) == Some(&b']') {
self.cursor += 1;
return None;
}
let child_path = bounded_array_path(path, index);
if let Some(found) = self.scan_value(&child_path, depth + 1) {
return Some(found);
}
index += 1;
self.skip_whitespace();
match self.input.get(self.cursor) {
Some(b',') => self.cursor += 1,
Some(b']') => {
self.cursor += 1;
return None;
}
_ => return None,
}
}
}

fn scan_string(&mut self) -> Option<String> {
let start = self.cursor;
if self.input.get(self.cursor) != Some(&b'"') {
return None;
}
self.cursor += 1;
while let Some(byte) = self.input.get(self.cursor).copied() {
match byte {
b'\\' => {
self.cursor += 2;
}
b'"' => {
self.cursor += 1;
return serde_json::from_slice(&self.input[start..self.cursor]).ok();
}
_ => self.cursor += 1,
}
}
None
}

fn scan_number(&mut self, path: &str) -> Option<String> {
let start = self.cursor;
if self.input.get(self.cursor) == Some(&b'-') {
self.cursor += 1;
}
self.consume_digits();
if self.input.get(self.cursor) == Some(&b'.') {
self.cursor += 1;
self.consume_digits();
}
if self
.input
.get(self.cursor)
.is_some_and(|byte| matches!(byte, b'e' | b'E'))
{
self.cursor += 1;
if self
.input
.get(self.cursor)
.is_some_and(|byte| matches!(byte, b'+' | b'-'))
{
self.cursor += 1;
}
self.consume_digits();
}
let token = std::str::from_utf8(&self.input[start..self.cursor]).ok()?;
token
.parse::<f64>()
.ok()
.filter(|number| !number.is_finite())
.map(|_| path.to_string())
}

fn consume_digits(&mut self) {
while self.input.get(self.cursor).is_some_and(u8::is_ascii_digit) {
self.cursor += 1;
}
}

fn consume_word(&mut self, word: &[u8]) -> bool {
if !self.input[self.cursor..].starts_with(word) {
return false;
}
let end = self.cursor + word.len();
if self
.input
.get(end)
.is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.'))
{
return false;
}
self.cursor = end;
true
}

fn skip_whitespace(&mut self) {
while self
.input
.get(self.cursor)
.is_some_and(u8::is_ascii_whitespace)
{
self.cursor += 1;
}
}
}

fn bounded_object_path(path: &str, key: &str) -> String {
if path == "$" || path.len() + key.len() + usize::from(!path.is_empty()) > RAW_NUMBER_MAX_PATH {
"$".to_string()
} else if path.is_empty() {
key.to_string()
} else {
format!("{path}.{key}")
}
}

fn bounded_array_path(path: &str, index: usize) -> String {
if path == "$" {
return "$".to_string();
}
let child = format!("{path}[{index}]");
if child.len() > RAW_NUMBER_MAX_PATH {
"$".to_string()
} else {
child
}
}

fn argument_relative_path(path: String) -> String {
for marker in ["params.arguments.", ".params.arguments."] {
if let Some(index) = path.find(marker) {
let relative = &path[index + marker.len()..];
return if safe_planned_non_finite_path(relative) {
relative.to_string()
} else {
"arguments".to_string()
};
}
}
"arguments".to_string()
}

fn safe_planned_non_finite_path(path: &str) -> bool {
let Some(index) = path
.strip_prefix("entries[")
.and_then(|tail| tail.strip_suffix("].startFrame"))
else {
return false;
};
!index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit())
}

/// Decode `dict` into `T` with the full three-layer guard:
/// 1. unknown-key rejection (incl. nested entries), 2. non-finite-number
/// rejection, 3. path-precise serde decode errors. 1:1 port of
Expand Down Expand Up @@ -304,6 +555,33 @@ mod tests {
);
}

#[test]
fn non_finite_number_rejected_with_path() {
for number in ["NaN", "Infinity", "-Infinity", "1e400"] {
let body = format!(
r#"{{"jsonrpc":"2.0","params":{{"arguments":{{"entries":[0,1,2,{{"startFrame":{number}}}]}}}}}}"#
);
assert_eq!(
first_non_finite_json_number_path(body.as_bytes()).as_deref(),
Some("entries[3].startFrame"),
"{number}"
);
}
assert_eq!(
first_non_finite_json_number_path(
br#"{"params":{"arguments":{"entries":[{"startFrame":120.5}]}}}"#
),
None
);
assert_eq!(
first_non_finite_json_number_path(
br#"{"params":{"arguments":{"callerOwnedSecret":Infinity}}}"#
)
.as_deref(),
Some("arguments")
);
}

#[test]
fn validate_unknown_keys_ok_when_subset() {
let map = serde_json::json!({"mediaRef":"m"});
Expand Down
11 changes: 4 additions & 7 deletions crates/opentake-agent/tests/mcp_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -507,13 +507,10 @@ async fn transport_rejects_nonfinite_numbers_before_dispatch() {
.expect("raw non-finite request sent");
let status = response.status();
let text = response.text().await.expect("parser response body");
assert!(
status.is_client_error()
&& (text.contains("deserialize")
|| text.contains("expected value")
|| text.contains("number out of range")
|| text.contains("\"error\"")),
"{number} was not rejected by the JSON/MCP parser: {status} {text}"
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST, "{number}: {text}");
assert_eq!(
text, "entries[3].startFrame: value must be finite",
"{number} path/message drifted"
);
assert_eq!(
calls.load(Ordering::Acquire),
Expand Down
Loading
Loading