use cpex_core::cmf::MessagePayload;
use cpex_core::error::PluginViolation;
use cpex_core::executor::ErasedResultFields;
use cpex_core::hooks::payload::PluginPayload;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::HostError;
use crate::legacy::payloads::{
IdentityResolvePayload, PromptPostFetchPayload, PromptPreFetchPayload,
ResourcePostFetchPayload, ResourcePreFetchPayload, TokenDelegatePayload, ToolPostInvokePayload,
ToolPreInvokePayload,
};
pub const CMF_PREFIX: &str = "cmf.";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenericPayload {
pub value: Value,
}
cpex_core::impl_plugin_payload!(GenericPayload);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PayloadKind {
CmfMessage,
ToolPreInvoke,
ToolPostInvoke,
PromptPreFetch,
PromptPostFetch,
ResourcePreFetch,
ResourcePostFetch,
IdentityResolve,
TokenDelegate,
Generic,
}
pub fn payload_kind_for_hook(hook_name: &str) -> PayloadKind {
use cpex_core::hooks::types::hook_names;
if hook_name.starts_with(CMF_PREFIX) {
return PayloadKind::CmfMessage;
}
match hook_name {
hook_names::TOOL_PRE_INVOKE => PayloadKind::ToolPreInvoke,
hook_names::TOOL_POST_INVOKE => PayloadKind::ToolPostInvoke,
hook_names::PROMPT_PRE_FETCH => PayloadKind::PromptPreFetch,
hook_names::PROMPT_POST_FETCH => PayloadKind::PromptPostFetch,
hook_names::RESOURCE_PRE_FETCH => PayloadKind::ResourcePreFetch,
hook_names::RESOURCE_POST_FETCH => PayloadKind::ResourcePostFetch,
hook_names::IDENTITY_RESOLVE => PayloadKind::IdentityResolve,
hook_names::TOKEN_DELEGATE => PayloadKind::TokenDelegate,
_ => PayloadKind::Generic,
}
}
pub fn is_credential_hook(hook_name: &str) -> bool {
matches!(
payload_kind_for_hook(hook_name),
PayloadKind::IdentityResolve | PayloadKind::TokenDelegate
)
}
pub fn serialize_payload(payload: &dyn PluginPayload) -> Result<Value, HostError> {
let any = payload.as_any();
macro_rules! try_downcast {
($($ty:ty),+ $(,)?) => {
$(
if let Some(concrete) = any.downcast_ref::<$ty>() {
return serde_json::to_value(concrete).map_err(|e| HostError::Protocol {
message: format!(
"could not serialize a {} payload: {e}",
stringify!($ty)
),
});
}
)+
};
}
try_downcast!(
MessagePayload,
ToolPreInvokePayload,
ToolPostInvokePayload,
PromptPreFetchPayload,
PromptPostFetchPayload,
ResourcePreFetchPayload,
ResourcePostFetchPayload,
IdentityResolvePayload,
TokenDelegatePayload,
);
if let Some(generic) = any.downcast_ref::<GenericPayload>() {
return Ok(generic.value.clone());
}
Err(HostError::Protocol {
message: "payload type is not one this host can forward to a Python worker \
(expected a CMF message payload, a legacy typed payload, or the generic payload)"
.into(),
})
}
pub fn deserialize_payload(
hook_name: &str,
value: Value,
) -> Result<Box<dyn PluginPayload>, HostError> {
fn parse<T>(value: Value, hook_name: &str) -> Result<Box<dyn PluginPayload>, HostError>
where
T: PluginPayload + serde::de::DeserializeOwned,
{
serde_json::from_value::<T>(value)
.map(|p| Box::new(p) as Box<dyn PluginPayload>)
.map_err(|e| HostError::Protocol {
message: format!("worker returned a modified payload that does not match hook '{hook_name}': {e}"),
})
}
match payload_kind_for_hook(hook_name) {
PayloadKind::CmfMessage => parse::<MessagePayload>(value, hook_name),
PayloadKind::ToolPreInvoke => parse::<ToolPreInvokePayload>(value, hook_name),
PayloadKind::ToolPostInvoke => parse::<ToolPostInvokePayload>(value, hook_name),
PayloadKind::PromptPreFetch => parse::<PromptPreFetchPayload>(value, hook_name),
PayloadKind::PromptPostFetch => parse::<PromptPostFetchPayload>(value, hook_name),
PayloadKind::ResourcePreFetch => parse::<ResourcePreFetchPayload>(value, hook_name),
PayloadKind::ResourcePostFetch => parse::<ResourcePostFetchPayload>(value, hook_name),
PayloadKind::IdentityResolve => parse::<IdentityResolvePayload>(value, hook_name),
PayloadKind::TokenDelegate => parse::<TokenDelegatePayload>(value, hook_name),
PayloadKind::Generic => Ok(Box::new(GenericPayload { value })),
}
}
pub fn response_to_result(
hook_name: &str,
response: Value,
inbound: &cpex_core::extensions::Extensions,
) -> Result<ErasedResultFields, HostError> {
let continue_processing = response
.get("continue_processing")
.and_then(Value::as_bool)
.unwrap_or(true);
let violation = match response.get("violation") {
Some(Value::Null) | None => None,
Some(raw) => Some(parse_violation(raw.clone())?),
};
let modified_payload = match response.get("modified_payload") {
Some(Value::Null) | None => None,
Some(raw) => Some(deserialize_payload(hook_name, raw.clone())?),
};
let modified_extensions = match response.get(crate::extensions::MODIFIED_EXTENSIONS_FIELD) {
Some(Value::Null) | None => None,
Some(raw) => crate::extensions::owned_from_returned_slot(raw, inbound)?,
};
Ok(ErasedResultFields {
continue_processing,
modified_payload,
modified_extensions,
violation,
})
}
fn parse_violation(raw: Value) -> Result<PluginViolation, HostError> {
let obj = raw.as_object().ok_or_else(|| HostError::Protocol {
message: "worker returned a violation that is not a JSON object".into(),
})?;
let code = obj.get("code").and_then(Value::as_str).unwrap_or_default();
let reason = obj
.get("reason")
.and_then(Value::as_str)
.unwrap_or_default();
let mut violation = PluginViolation::new(code, reason);
violation.description = obj
.get("description")
.and_then(Value::as_str)
.map(str::to_string);
if let Some(details) = obj.get("details").and_then(Value::as_object) {
violation.details = details.clone().into_iter().collect();
}
violation.proto_error_code = obj
.get("mcp_error_code")
.or_else(|| obj.get("proto_error_code"))
.and_then(Value::as_i64);
Ok(violation)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn no_inbound() -> cpex_core::extensions::Extensions {
cpex_core::extensions::Extensions::default()
}
#[test]
fn cmf_hooks_route_to_the_message_payload() {
assert_eq!(
payload_kind_for_hook("cmf.tool_pre_invoke"),
PayloadKind::CmfMessage
);
assert_eq!(
payload_kind_for_hook("cmf.llm_input"),
PayloadKind::CmfMessage
);
assert_eq!(
payload_kind_for_hook("cmf.some_future_hook"),
PayloadKind::CmfMessage,
"any cmf.* name is a message payload, even one this host predates"
);
}
#[test]
fn every_legacy_hook_resolves_to_its_own_typed_payload() {
let expected = [
("tool_pre_invoke", PayloadKind::ToolPreInvoke),
("tool_post_invoke", PayloadKind::ToolPostInvoke),
("prompt_pre_fetch", PayloadKind::PromptPreFetch),
("prompt_post_fetch", PayloadKind::PromptPostFetch),
("resource_pre_fetch", PayloadKind::ResourcePreFetch),
("resource_post_fetch", PayloadKind::ResourcePostFetch),
("identity_resolve", PayloadKind::IdentityResolve),
("token_delegate", PayloadKind::TokenDelegate),
];
for (hook, kind) in expected {
assert_eq!(
payload_kind_for_hook(hook),
kind,
"hook '{hook}' resolved wrongly"
);
}
let kinds: std::collections::HashSet<_> =
expected.iter().map(|(_, k)| format!("{k:?}")).collect();
assert_eq!(
kinds.len(),
8,
"each legacy hook needs its own payload kind"
);
}
#[test]
fn an_unknown_hook_name_falls_back_to_the_generic_payload() {
assert_eq!(
payload_kind_for_hook("some_custom_hook"),
PayloadKind::Generic
);
assert_eq!(payload_kind_for_hook(""), PayloadKind::Generic);
}
#[test]
fn only_identity_and_delegation_are_credential_hooks() {
assert!(is_credential_hook("identity_resolve"));
assert!(is_credential_hook("token_delegate"));
for hook in [
"tool_pre_invoke",
"tool_post_invoke",
"prompt_pre_fetch",
"resource_pre_fetch",
"cmf.tool_pre_invoke",
"unknown",
] {
assert!(
!is_credential_hook(hook),
"'{hook}' must not receive credentials"
);
}
}
#[test]
fn a_tool_pre_invoke_payload_serializes_to_the_worker_field_shape() {
let payload = ToolPreInvokePayload {
name: "search".into(),
args: Some(HashMap::from([("q".into(), serde_json::json!("rust"))])),
headers: Some(HashMap::from([("X-Tenant".into(), "acme".into())])),
};
let json = serialize_payload(&payload).expect("serializes");
assert_eq!(json["name"], "search");
assert_eq!(json["args"]["q"], "rust");
assert_eq!(json["headers"]["X-Tenant"], "acme");
}
#[test]
fn the_generic_payload_serializes_to_its_inner_value() {
let payload = GenericPayload {
value: serde_json::json!({"anything": [1, 2, 3]}),
};
let json = serialize_payload(&payload).expect("serializes");
assert_eq!(json["anything"][2], 3);
assert!(
json.get("value").is_none(),
"the wrapper must not appear on the wire"
);
}
#[test]
fn a_cmf_message_payload_serializes_as_itself() {
use cpex_core::cmf::{Message, Role};
let payload = MessagePayload {
message: Message::text(Role::User, "what is the weather?"),
};
let json = serialize_payload(&payload).expect("serializes");
assert_eq!(json["message"]["role"], "user");
}
#[test]
fn an_unforwardable_payload_type_errors_rather_than_sending_null() {
#[derive(Debug, Clone)]
struct ForeignPayload;
cpex_core::impl_plugin_payload!(ForeignPayload);
let err =
serialize_payload(&ForeignPayload).expect_err("an unknown type cannot be forwarded");
assert!(matches!(err, HostError::Protocol { .. }), "got {err:?}");
}
#[test]
fn a_modified_payload_deserializes_into_the_hooks_typed_payload() {
let boxed =
deserialize_payload("tool_pre_invoke", serde_json::json!({ "name": "redacted" }))
.expect("parses");
let typed = boxed
.as_any()
.downcast_ref::<ToolPreInvokePayload>()
.expect("must come back as the tool payload, or the executor's downcast fails");
assert_eq!(typed.name, "redacted");
}
#[test]
fn a_cmf_modified_payload_deserializes_as_a_message_payload() {
use cpex_core::cmf::{Message, Role};
let original = MessagePayload {
message: Message::text(Role::Assistant, "redacted"),
};
let json = serde_json::to_value(&original).unwrap();
let boxed = deserialize_payload("cmf.tool_pre_invoke", json).expect("parses");
let typed = boxed
.as_any()
.downcast_ref::<MessagePayload>()
.expect("a cmf hook's modified payload must come back as a MessagePayload");
assert_eq!(typed.message.role, Role::Assistant);
}
#[test]
fn an_unknown_hooks_modified_payload_becomes_a_generic_payload() {
let boxed =
deserialize_payload("mystery_hook", serde_json::json!({"x": 1})).expect("parses");
let generic = boxed
.as_any()
.downcast_ref::<GenericPayload>()
.expect("generic");
assert_eq!(generic.value["x"], 1);
}
#[test]
fn a_mismatched_modified_payload_errors_and_names_the_hook() {
let err = deserialize_payload("tool_pre_invoke", serde_json::json!({ "unexpected": true }))
.expect_err("a payload missing required fields must be rejected");
assert!(err.to_string().contains("tool_pre_invoke"), "{err}");
}
#[test]
fn an_allow_response_becomes_an_allow_result() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({ "continue_processing": true }),
&no_inbound(),
)
.expect("converts");
assert!(fields.continue_processing);
assert!(fields.violation.is_none());
assert!(fields.modified_payload.is_none());
assert!(fields.modified_extensions.is_none());
}
#[test]
fn a_response_without_continue_processing_defaults_to_allow() {
let fields = response_to_result("tool_pre_invoke", serde_json::json!({}), &no_inbound())
.expect("converts");
assert!(fields.continue_processing);
}
#[test]
fn a_deny_response_carries_its_violation() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({
"continue_processing": false,
"violation": {
"code": "PII_DETECTED",
"reason": "email address in args",
"description": "matched the email pattern",
"details": {"field": "q"},
"mcp_error_code": -32603
}
}),
&no_inbound(),
)
.expect("converts");
assert!(!fields.continue_processing);
let violation = fields.violation.expect("a deny must carry its violation");
assert_eq!(violation.code, "PII_DETECTED");
assert_eq!(violation.reason, "email address in args");
assert_eq!(
violation.description.as_deref(),
Some("matched the email pattern")
);
assert_eq!(violation.details["field"], "q");
assert_eq!(
violation.proto_error_code,
Some(-32603),
"the Python mcp_error_code maps onto the Rust proto_error_code"
);
}
#[test]
fn a_violation_with_only_a_reason_still_denies() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({ "continue_processing": false, "violation": { "reason": "nope" } }),
&no_inbound(),
)
.expect("a sparse violation is still a violation");
assert!(!fields.continue_processing);
let violation = fields.violation.unwrap();
assert_eq!(violation.reason, "nope");
assert_eq!(violation.code, "");
}
#[test]
fn a_modified_payload_survives_the_round_trip() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({
"continue_processing": true,
"modified_payload": { "name": "search", "args": {"q": "[REDACTED]"} }
}),
&no_inbound(),
)
.expect("converts");
let payload = fields
.modified_payload
.expect("the modification must survive");
let typed = payload
.as_any()
.downcast_ref::<ToolPreInvokePayload>()
.unwrap();
assert_eq!(typed.args.as_ref().unwrap()["q"], "[REDACTED]");
}
#[test]
fn modified_extensions_survive_the_round_trip() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({
"continue_processing": true,
"modified_extensions": { "custom": { "seen_by": "py-plugin" } }
}),
&no_inbound(),
)
.expect("converts");
let extensions = fields.modified_extensions.expect("extensions must survive");
assert_eq!(
extensions.custom.expect("custom slot")["seen_by"],
"py-plugin"
);
}
#[test]
fn a_gated_slot_write_without_the_capability_is_dropped() {
for slot in ["http", "security", "delegation"] {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({
"continue_processing": true,
"modified_extensions": { slot: {"labels": ["PII"]} }
}),
&no_inbound(),
)
.expect("an unauthorized gated write is dropped, not a protocol error");
assert!(
fields.modified_extensions.is_none(),
"the '{slot}' write had no token behind it, so nothing should merge"
);
}
}
#[test]
fn an_extensions_object_with_only_gated_nulls_is_no_modification() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({
"continue_processing": true,
"modified_extensions": { "http": null, "security": null, "custom": null }
}),
&no_inbound(),
)
.expect("all-null slots are not a write");
assert!(fields.modified_extensions.is_none());
}
#[test]
fn explicit_nulls_are_treated_as_absent() {
let fields = response_to_result(
"tool_pre_invoke",
serde_json::json!({
"continue_processing": true,
"modified_payload": null,
"modified_extensions": null,
"violation": null
}),
&no_inbound(),
)
.expect("nulls are absent, not malformed");
assert!(fields.modified_payload.is_none());
assert!(fields.modified_extensions.is_none());
assert!(fields.violation.is_none());
}
#[test]
fn a_malformed_modified_payload_surfaces_as_a_protocol_error() {
let Err(err) = response_to_result(
"tool_pre_invoke",
serde_json::json!({ "continue_processing": true, "modified_payload": "not an object" }),
&no_inbound(),
) else {
panic!("a payload that cannot be rebuilt must not be silently dropped");
};
assert!(matches!(err, HostError::Protocol { .. }), "got {err:?}");
}
}