diff --git a/src/environmentd/tests/server.rs b/src/environmentd/tests/server.rs index b63fcf3a2a9ad..ef6d8b963974c 100644 --- a/src/environmentd/tests/server.rs +++ b/src/environmentd/tests/server.rs @@ -5412,8 +5412,22 @@ fn run_mcp_datadriven_inner( other => panic!("unknown directive: {}", other), }; - let json: serde_json::Value = serde_json::from_str(&tc.input).unwrap(); - let res = Client::new().post(url).json(&json).send().unwrap(); + // Directive args: `get` sends a GET request instead of POST (the + // input is ignored), and `origin` attaches a non-allowlisted + // Origin header to exercise the DNS-rebinding defense. (Datadriven + // arg values cannot contain `:` or `/`, so the origin value is a + // fixed constant here rather than a directive parameter.) + let client = Client::new(); + let mut req = if tc.args.contains_key("get") { + client.get(url) + } else { + let json: serde_json::Value = serde_json::from_str(&tc.input).unwrap(); + client.post(url).json(&json) + }; + if tc.args.contains_key("origin") { + req = req.header("origin", "https://evil.example.com"); + } + let res = req.send().unwrap(); let status = res.status(); let body = res.text().unwrap(); @@ -5504,6 +5518,18 @@ fn test_mcp_developer_disabled() { run_mcp_datadriven("tests/testdata/mcp/developer_disabled", harness); } +/// Tests that MCP tool responses larger than `mcp_max_response_size` are +/// rejected with an error telling the agent to narrow the query, instead of +/// returning an unbounded payload. +#[mz_ore::test] +fn test_mcp_developer_response_size_limit() { + let harness = test_util::TestHarness::default() + .with_mcp_routes(false, true) + .with_system_parameter_default("enable_mcp_developer".to_string(), "true".to_string()) + .with_system_parameter_default("mcp_max_response_size".to_string(), "1024".to_string()); + run_mcp_datadriven("tests/testdata/mcp/developer_response_limit", harness); +} + /// Regression test for database-issues#11320. /// /// The developer endpoint validator allows unqualified `mz_*` and `pg_*` table diff --git a/src/environmentd/tests/testdata/mcp/agent b/src/environmentd/tests/testdata/mcp/agent index e4c33ed89d441..1742d0f44ac59 100644 --- a/src/environmentd/tests/testdata/mcp/agent +++ b/src/environmentd/tests/testdata/mcp/agent @@ -121,3 +121,36 @@ mcp-agent {"jsonrpc":"2.0","id":null,"method":"tools/list"} ---- 200 OK + +# ============================================================================= +# HTTP method enforcement +# ============================================================================= + +# MCP spec 2025-11-25: GET on the POST-only endpoint returns 405. +mcp-agent get +---- +405 Method Not Allowed + +# ============================================================================= +# Origin validation (DNS rebinding defense) +# ============================================================================= + +# Browser-style requests with an Origin header are rejected unless the origin +# is on the CORS allowlist (empty in this test harness). Requests without an +# Origin header (every other test in this file) are allowed. +mcp-agent origin +{"jsonrpc":"2.0","id":40,"method":"tools/list"} +---- +403 Forbidden + +# ============================================================================= +# read_data_product - name validation +# ============================================================================= + +# A data product name that does not parse as a single object name (e.g. an +# SQL injection attempt) is rejected by validation, not interpolated into SQL. +mcp-agent +{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"read_data_product","arguments":{"name":"users\"; DROP TABLE users; --"}}} +---- +200 OK +{"jsonrpc":"2.0","id":41,"error":{"code":-32602,"message":"Query validation failed: Invalid data product name: users\"; DROP TABLE users; --. Expected a valid object name, e.g. '\"database\".\"schema\".\"name\"' or 'my_view'","data":{"error_type":"ValidationError"}}} diff --git a/src/environmentd/tests/testdata/mcp/developer b/src/environmentd/tests/testdata/mcp/developer index a38017706e18e..dc9bc02b3a1bf 100644 --- a/src/environmentd/tests/testdata/mcp/developer +++ b/src/environmentd/tests/testdata/mcp/developer @@ -233,3 +233,43 @@ mcp-developer ---- 200 OK {"jsonrpc":"2.0","id":54,"error":{"code":-32602,"message":"Query validation failed: Query references non-system tables: public.user_data. Only system catalog tables (mz_*, pg_catalog, information_schema) are allowed.","data":{"error_type":"ValidationError"}}} + +# ============================================================================= +# HTTP method enforcement and Origin validation +# ============================================================================= + +# MCP spec 2025-11-25: GET on the POST-only endpoint returns 405. +mcp-developer get +---- +405 Method Not Allowed + +# Browser-style requests with an Origin header are rejected unless the origin +# is on the CORS allowlist (empty in this test harness). Requests without an +# Origin header (every other test in this file) are allowed. +mcp-developer origin +{"jsonrpc":"2.0","id":60,"method":"tools/list"} +---- +403 Forbidden + +# ============================================================================= +# query_system_catalog - mz_unsafe exclusion +# ============================================================================= + +# mz_unsafe is not on the system catalog allowlist even though it is a system +# schema, so references to it are rejected at validation. +mcp-developer +{"jsonrpc":"2.0","id":61,"method":"tools/call","params":{"name":"query_system_catalog","arguments":{"sql_query":"SELECT * FROM mz_unsafe.mz_sleep_forever"}}} +---- +200 OK +{"jsonrpc":"2.0","id":61,"error":{"code":-32602,"message":"Query validation failed: Query references non-system tables: mz_unsafe.mz_sleep_forever. Only system catalog tables (mz_*, pg_catalog, information_schema) are allowed.","data":{"error_type":"ValidationError"}}} + +# ============================================================================= +# Null id notification +# ============================================================================= + +# Null id is treated as a notification by serde (Option deserializes +# null as None): 200 with no response body. +mcp-developer +{"jsonrpc":"2.0","id":null,"method":"tools/list"} +---- +200 OK diff --git a/src/environmentd/tests/testdata/mcp/developer_query_tool b/src/environmentd/tests/testdata/mcp/developer_query_tool index 610ff4272d366..724d56630c392 100644 --- a/src/environmentd/tests/testdata/mcp/developer_query_tool +++ b/src/environmentd/tests/testdata/mcp/developer_query_tool @@ -183,3 +183,29 @@ mcp-developer ---- 200 OK {"jsonrpc":"2.0","id":50,"result":{"content":[{"type":"text","text":"[\n [\n \"materialize\"\n ]\n]"}],"isError":false}} + +# ============================================================================= +# query - cluster_replica validation +# ============================================================================= + +# Empty cluster_replica is rejected by validation before reaching the engine. +mcp-developer +{"jsonrpc":"2.0","id":60,"method":"tools/call","params":{"name":"query","arguments":{"cluster":"quickstart","cluster_replica":"","sql_query":"SELECT 1"}}} +---- +200 OK +{"jsonrpc":"2.0","id":60,"error":{"code":-32602,"message":"Query validation failed: cluster_replica must not be empty or whitespace-only","data":{"error_type":"ValidationError"}}} + +# Whitespace-only cluster_replica is rejected too. +mcp-developer +{"jsonrpc":"2.0","id":61,"method":"tools/call","params":{"name":"query","arguments":{"cluster":"quickstart","cluster_replica":" ","sql_query":"SELECT 1"}}} +---- +200 OK +{"jsonrpc":"2.0","id":61,"error":{"code":-32602,"message":"Query validation failed: cluster_replica must not be empty or whitespace-only","data":{"error_type":"ValidationError"}}} + +# A replica that does not exist on the target cluster fails at execution with +# an error naming the replica, so agents can self-correct. +mcp-developer +{"jsonrpc":"2.0","id":62,"method":"tools/call","params":{"name":"query","arguments":{"cluster":"quickstart","cluster_replica":"r99","sql_query":"SELECT 1"}}} +---- +200 OK +{"jsonrpc":"2.0","id":62,"error":{"code":-32603,"message":"Query execution failed: cluster replica 'quickstart.r99' does not exist","data":{"error_type":"ExecutionError"}}} diff --git a/src/environmentd/tests/testdata/mcp/developer_response_limit b/src/environmentd/tests/testdata/mcp/developer_response_limit new file mode 100644 index 0000000000000..91b87971d29f3 --- /dev/null +++ b/src/environmentd/tests/testdata/mcp/developer_response_limit @@ -0,0 +1,28 @@ +# Copyright Materialize, Inc. and contributors. All rights reserved. +# +# Use of this software is governed by the Business Source License +# included in the LICENSE file at the root of this repository. +# +# As of the Change Date specified in that file, in accordance with +# the Business Source License, use of this software will be governed +# by the Apache License, Version 2.0. + + +# ============================================================================= +# Response size limit (mcp_max_response_size = 1024 for this test) +# ============================================================================= + +# A response comfortably under the limit is returned untouched. +mcp-developer +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"query_system_catalog","arguments":{"sql_query":"SELECT name FROM mz_databases WHERE name = 'materialize'"}}} +---- +200 OK +{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"[\n [\n \"materialize\"\n ]\n]"}],"isError":false}} + +# Responses over the limit are rejected with an error telling the agent to +# narrow its query. repeat() gives a deterministic response size. +mcp-developer +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"query_system_catalog","arguments":{"sql_query":"SELECT repeat('x', 5000) FROM mz_catalog.mz_databases WHERE name = 'materialize'"}}} +---- +200 OK +{"jsonrpc":"2.0","id":2,"error":{"code":-32603,"message":"Query execution failed: Response size (5018 bytes) exceeds the 1024 byte limit. Use LIMIT or WHERE to narrow your query.","data":{"error_type":"ExecutionError"}}}