Skip to content

Commit b93220d

Browse files
authored
feat(conformance): add SEP-2243 header validation tool (modelcontextprotocol#997)
1 parent 839922d commit b93220d

3 files changed

Lines changed: 82 additions & 1 deletion

File tree

.github/workflows/conformance.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ jobs:
3434
- name: Build conformance binaries
3535
run: cargo build -p mcp-conformance
3636

37+
- name: Test conformance server
38+
run: cargo test -p mcp-conformance --bin conformance-server
39+
3740
- name: Start conformance server
3841
run: |
3942
PORT=8001 ./target/debug/conformance-server &
@@ -80,7 +83,12 @@ jobs:
8083
8184
- name: Run draft SEP scenarios
8285
run: |
83-
for scenario in sep-2164-resource-not-found caching http-header-validation; do
86+
for scenario in \
87+
sep-2164-resource-not-found \
88+
caching \
89+
http-header-validation \
90+
http-custom-header-server-validation \
91+
; do
8492
npx -y "@modelcontextprotocol/conformance@${DRAFT_CONFORMANCE_VERSION}" server \
8593
--url http://127.0.0.1:8002/mcp \
8694
--scenario "$scenario" \

conformance/src/bin/server.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ fn json_object(v: Value) -> JsonObject {
2828
}
2929
}
3030

31+
fn custom_header_tool() -> Tool {
32+
Tool::new(
33+
"test_custom_header",
34+
"Validates SEP-2243 custom parameter headers",
35+
json_object(json!({
36+
"type": "object",
37+
"properties": {
38+
"value": { "type": "string", "x-mcp-header": "Value" }
39+
},
40+
"required": ["value"]
41+
})),
42+
)
43+
}
44+
3145
/// Signing key for SEP-2322 `requestState` sealing. A fixed key is fine for a
3246
/// conformance harness; real servers must load a secret out of clients' reach.
3347
const REQUEST_STATE_KEY: &[u8] = b"rust-sdk-conformance-request-state-key!!";
@@ -361,6 +375,10 @@ impl ConformanceServer {
361375
}
362376

363377
impl ServerHandler for ConformanceServer {
378+
fn get_tool(&self, name: &str) -> Option<Tool> {
379+
(name == "test_custom_header").then(custom_header_tool)
380+
}
381+
364382
async fn initialize(
365383
&self,
366384
request: InitializeRequestParams,
@@ -521,6 +539,7 @@ impl ServerHandler for ConformanceServer {
521539
"properties": {}
522540
})),
523541
),
542+
custom_header_tool(),
524543
];
525544
// SEP-2322 MRTR test tools; all take no arguments.
526545
let mrtr_tools = [
@@ -668,6 +687,14 @@ impl ServerHandler for ConformanceServer {
668687
)]))
669688
}
670689

690+
"test_custom_header" => {
691+
let value = args
692+
.get("value")
693+
.and_then(Value::as_str)
694+
.ok_or_else(|| ErrorData::invalid_params("value must be a string", None))?;
695+
Ok(CallToolResult::success(vec![ContentBlock::text(value)]))
696+
}
697+
671698
"test_sampling" => {
672699
let prompt = args
673700
.get("prompt")
@@ -1218,3 +1245,26 @@ async fn main() -> anyhow::Result<()> {
12181245

12191246
Ok(())
12201247
}
1248+
1249+
#[cfg(test)]
1250+
mod tests {
1251+
use super::*;
1252+
1253+
#[test]
1254+
fn server_exposes_custom_header_tool_for_transport_validation() {
1255+
let tool = ConformanceServer::new()
1256+
.get_tool("test_custom_header")
1257+
.expect("custom-header conformance tool");
1258+
let value = Value::Object((*tool.input_schema).clone());
1259+
1260+
assert_eq!(
1261+
value.pointer("/properties/value/type"),
1262+
Some(&json!("string"))
1263+
);
1264+
assert_eq!(
1265+
value.pointer("/properties/value/x-mcp-header"),
1266+
Some(&json!("Value"))
1267+
);
1268+
assert_eq!(value.pointer("/required/0"), Some(&json!("value")));
1269+
}
1270+
}

crates/rmcp/tests/test_streamable_http_standard_headers.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,29 @@ async fn rejects_param_mismatch_with_32020() -> anyhow::Result<()> {
267267
Ok(())
268268
}
269269

270+
#[tokio::test]
271+
async fn rejects_decoded_base64_param_mismatch_with_32020() -> anyhow::Result<()> {
272+
let (client, url, ct) = spawn_server().await;
273+
274+
let response = post_tool_call(
275+
&client,
276+
&url,
277+
SEP_VERSION,
278+
"deploy",
279+
serde_json::json!({ "region": "us-west1" }),
280+
Some("tools/call"),
281+
Some("deploy"),
282+
Some("=?base64?ZXUtY2VudHJhbDE=?="),
283+
)
284+
.await;
285+
assert_eq!(response.status(), 400);
286+
let body: serde_json::Value = response.json().await?;
287+
assert_eq!(body["error"]["code"], -32020);
288+
289+
ct.cancel();
290+
Ok(())
291+
}
292+
270293
#[tokio::test]
271294
async fn rejects_missing_param_header_with_32020() -> anyhow::Result<()> {
272295
let (client, url, ct) = spawn_server().await;

0 commit comments

Comments
 (0)