use serde::{Deserialize, Serialize};
use crate::protocol::core::challenge::{PaymentChallenge, PaymentCredential, Receipt};
pub const PAYMENT_REQUIRED_CODE: i32 = -32042;
pub const PAYMENT_VERIFICATION_FAILED_CODE: i32 = -32043;
pub const CREDENTIAL_META_KEY: &str = "org.paymentauth/credential";
pub const RECEIPT_META_KEY: &str = "org.paymentauth/receipt";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpReceipt {
#[serde(flatten)]
pub receipt: Receipt,
#[serde(rename = "challengeId")]
pub challenge_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpPaymentError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<McpPaymentErrorData>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpPaymentErrorData {
#[serde(rename = "httpStatus")]
pub http_status: u16,
pub challenges: Vec<PaymentChallenge>,
#[serde(skip_serializing_if = "Option::is_none")]
pub problem: Option<crate::error::PaymentErrorDetails>,
}
pub fn extract_credential(meta: &serde_json::Value) -> Option<PaymentCredential> {
let cred_value = meta.get(CREDENTIAL_META_KEY)?;
serde_json::from_value(cred_value.clone()).ok()
}
pub fn payment_required_error(challenge: &PaymentChallenge) -> McpPaymentError {
McpPaymentError {
code: PAYMENT_REQUIRED_CODE,
message: "Payment Required".to_string(),
data: Some(McpPaymentErrorData {
http_status: 402,
challenges: vec![challenge.clone()],
problem: None,
}),
}
}
pub fn attach_receipt(result: &mut serde_json::Value, receipt: &Receipt, challenge_id: &str) {
let mcp_receipt = McpReceipt {
receipt: receipt.clone(),
challenge_id: challenge_id.to_string(),
};
let receipt_value =
serde_json::to_value(&mcp_receipt).expect("McpReceipt must be serializable");
let meta = result
.as_object_mut()
.expect("result must be a JSON object")
.entry("_meta")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
meta.as_object_mut()
.expect("_meta must be a JSON object")
.insert(RECEIPT_META_KEY.to_string(), receipt_value);
}
pub fn is_payment_required(error: &serde_json::Value) -> bool {
error
.get("code")
.and_then(|c| c.as_i64())
.is_some_and(|c| c == PAYMENT_REQUIRED_CODE as i64)
}
pub fn extract_challenges(error: &serde_json::Value) -> Option<Vec<PaymentChallenge>> {
let challenges_value = error.get("data")?.get("challenges")?;
serde_json::from_value(challenges_value.clone()).ok()
}
pub fn attach_credential(params: &mut serde_json::Value, credential: &PaymentCredential) {
let cred_value =
serde_json::to_value(credential).expect("PaymentCredential must be serializable");
let meta = params
.as_object_mut()
.expect("params must be a JSON object")
.entry("_meta")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
meta.as_object_mut()
.expect("_meta must be a JSON object")
.insert(CREDENTIAL_META_KEY.to_string(), cred_value);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::core::challenge::{ChallengeEcho, PaymentPayload};
use crate::protocol::core::types::Base64UrlJson;
use serde_json::json;
fn test_challenge() -> PaymentChallenge {
PaymentChallenge::new(
"ch_test_123",
"api.example.com",
"tempo",
"charge",
Base64UrlJson::from_value(&json!({"amount": "1000", "currency": "USD"})).unwrap(),
)
}
fn test_credential() -> PaymentCredential {
PaymentCredential::with_source(
ChallengeEcho {
id: "ch_test_123".to_string(),
realm: "api.example.com".to_string(),
method: "tempo".into(),
intent: "charge".into(),
request: Base64UrlJson::from_raw("eyJhbW91bnQiOiIxMDAwIn0"),
expires: None,
digest: None,
opaque: None,
},
"did:pkh:eip155:42161:0xabc",
PaymentPayload::transaction("0xdeadbeef"),
)
}
fn test_receipt() -> Receipt {
Receipt::success("tempo", "0xtxhash123")
}
#[test]
fn test_mcp_payment_error_roundtrip() {
let challenge = test_challenge();
let error = payment_required_error(&challenge);
let json = serde_json::to_string(&error).unwrap();
let parsed: McpPaymentError = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.code, PAYMENT_REQUIRED_CODE);
assert_eq!(parsed.message, "Payment Required");
let data = parsed.data.unwrap();
assert_eq!(data.http_status, 402);
assert_eq!(data.challenges.len(), 1);
assert_eq!(data.challenges[0].id, "ch_test_123");
}
#[test]
fn test_mcp_payment_error_without_data() {
let error = McpPaymentError {
code: PAYMENT_REQUIRED_CODE,
message: "Payment Required".to_string(),
data: None,
};
let json = serde_json::to_string(&error).unwrap();
let parsed: McpPaymentError = serde_json::from_str(&json).unwrap();
assert!(parsed.data.is_none());
}
#[test]
fn test_extract_credential_valid() {
let cred = test_credential();
let meta = json!({
CREDENTIAL_META_KEY: cred,
});
let extracted = extract_credential(&meta).unwrap();
assert_eq!(extracted.challenge.id, "ch_test_123");
assert_eq!(
extracted.source.as_deref(),
Some("did:pkh:eip155:42161:0xabc")
);
}
#[test]
fn test_extract_credential_missing() {
let meta = json!({});
assert!(extract_credential(&meta).is_none());
}
#[test]
fn test_extract_credential_malformed() {
let meta = json!({
CREDENTIAL_META_KEY: "not-a-valid-credential",
});
assert!(extract_credential(&meta).is_none());
}
#[test]
fn test_extract_credential_null_value() {
let meta = json!({
CREDENTIAL_META_KEY: null,
});
assert!(extract_credential(&meta).is_none());
}
#[test]
fn test_payment_required_error_construction() {
let challenge = test_challenge();
let error = payment_required_error(&challenge);
assert_eq!(error.code, PAYMENT_REQUIRED_CODE);
assert_eq!(error.message, "Payment Required");
let data = error.data.as_ref().unwrap();
assert_eq!(data.http_status, 402);
assert_eq!(data.challenges.len(), 1);
assert_eq!(data.challenges[0].method.as_str(), "tempo");
assert_eq!(data.challenges[0].intent.as_str(), "charge");
}
#[test]
fn test_attach_receipt_to_empty_result() {
let receipt = test_receipt();
let mut result = json!({});
attach_receipt(&mut result, &receipt, "ch_test_123");
let meta = result.get("_meta").unwrap();
let mcp_receipt = meta.get(RECEIPT_META_KEY).unwrap();
assert_eq!(mcp_receipt["status"], "success");
assert_eq!(mcp_receipt["method"], "tempo");
assert_eq!(mcp_receipt["reference"], "0xtxhash123");
assert_eq!(mcp_receipt["challengeId"], "ch_test_123");
}
#[test]
fn test_attach_receipt_preserves_existing_meta() {
let receipt = test_receipt();
let mut result = json!({
"_meta": {
"other_key": "other_value"
},
"content": [{"type": "text", "text": "hello"}]
});
attach_receipt(&mut result, &receipt, "ch_456");
let meta = result.get("_meta").unwrap();
assert_eq!(meta["other_key"], "other_value");
assert!(meta.get(RECEIPT_META_KEY).is_some());
assert_eq!(result["content"][0]["text"], "hello");
}
#[test]
fn test_attach_receipt_deserializes_as_mcp_receipt() {
let receipt = test_receipt();
let mut result = json!({});
attach_receipt(&mut result, &receipt, "ch_789");
let receipt_value = result["_meta"][RECEIPT_META_KEY].clone();
let mcp_receipt: McpReceipt = serde_json::from_value(receipt_value).unwrap();
assert_eq!(mcp_receipt.challenge_id, "ch_789");
assert!(mcp_receipt.receipt.is_success());
}
#[test]
fn test_is_payment_required_matching() {
let error = json!({
"code": PAYMENT_REQUIRED_CODE,
"message": "Payment Required"
});
assert!(is_payment_required(&error));
}
#[test]
fn test_is_payment_required_wrong_code() {
let error = json!({
"code": -32600,
"message": "Invalid Request"
});
assert!(!is_payment_required(&error));
}
#[test]
fn test_is_payment_required_verification_failed_code() {
let error = json!({
"code": PAYMENT_VERIFICATION_FAILED_CODE,
"message": "Payment Verification Failed"
});
assert!(!is_payment_required(&error));
}
#[test]
fn test_is_payment_required_no_code() {
let error = json!({"message": "something"});
assert!(!is_payment_required(&error));
}
#[test]
fn test_extract_challenges_valid() {
let challenge = test_challenge();
let error = json!({
"code": PAYMENT_REQUIRED_CODE,
"message": "Payment Required",
"data": {
"httpStatus": 402,
"challenges": [challenge]
}
});
let challenges = extract_challenges(&error).unwrap();
assert_eq!(challenges.len(), 1);
assert_eq!(challenges[0].id, "ch_test_123");
}
#[test]
fn test_extract_challenges_no_data() {
let error = json!({
"code": PAYMENT_REQUIRED_CODE,
"message": "Payment Required"
});
assert!(extract_challenges(&error).is_none());
}
#[test]
fn test_extract_challenges_no_challenges_field() {
let error = json!({
"code": PAYMENT_REQUIRED_CODE,
"data": {"httpStatus": 402}
});
assert!(extract_challenges(&error).is_none());
}
#[test]
fn test_extract_challenges_multiple() {
let c1 = test_challenge();
let mut c2 = test_challenge();
c2.id = "ch_test_456".to_string();
c2.method = "base".into();
let error = json!({
"code": PAYMENT_REQUIRED_CODE,
"data": {
"httpStatus": 402,
"challenges": [c1, c2]
}
});
let challenges = extract_challenges(&error).unwrap();
assert_eq!(challenges.len(), 2);
assert_eq!(challenges[1].method.as_str(), "base");
}
#[test]
fn test_attach_credential_to_empty_params() {
let cred = test_credential();
let mut params = json!({"name": "premium_tool"});
attach_credential(&mut params, &cred);
let meta = params.get("_meta").unwrap();
let cred_value = meta.get(CREDENTIAL_META_KEY).unwrap();
assert_eq!(cred_value["challenge"]["id"], "ch_test_123");
}
#[test]
fn test_attach_credential_preserves_existing_meta() {
let cred = test_credential();
let mut params = json!({
"name": "tool",
"_meta": {"progressToken": 42}
});
attach_credential(&mut params, &cred);
let meta = params.get("_meta").unwrap();
assert_eq!(meta["progressToken"], 42);
assert!(meta.get(CREDENTIAL_META_KEY).is_some());
}
#[test]
fn test_attach_credential_preserves_params() {
let cred = test_credential();
let mut params = json!({
"name": "tool",
"arguments": {"query": "test"}
});
attach_credential(&mut params, &cred);
assert_eq!(params["name"], "tool");
assert_eq!(params["arguments"]["query"], "test");
}
#[test]
fn test_mcp_receipt_serde_roundtrip() {
let mcp_receipt = McpReceipt {
receipt: test_receipt(),
challenge_id: "ch_abc".to_string(),
};
let json = serde_json::to_value(&mcp_receipt).unwrap();
assert_eq!(json["status"], "success");
assert_eq!(json["method"], "tempo");
assert_eq!(json["challengeId"], "ch_abc");
let parsed: McpReceipt = serde_json::from_value(json).unwrap();
assert_eq!(parsed.challenge_id, "ch_abc");
assert!(parsed.receipt.is_success());
}
#[test]
fn test_mcp_payment_roundtrip() {
let secret = "mcp-test-secret";
let challenge = PaymentChallenge::with_secret_key(
secret,
"api.example.com",
"tempo",
"charge",
Base64UrlJson::from_value(&json!({"amount": "1000", "currency": "USD"})).unwrap(),
);
assert!(challenge.verify(secret));
let error = payment_required_error(&challenge);
let error_json = serde_json::to_value(&error).unwrap();
assert!(is_payment_required(&error_json));
let challenges = extract_challenges(&error_json).unwrap();
assert_eq!(challenges.len(), 1);
assert_eq!(challenges[0].id, challenge.id);
assert_eq!(challenges[0].method.as_str(), "tempo");
assert_eq!(challenges[0].intent.as_str(), "charge");
let received = &challenges[0];
let credential = PaymentCredential::with_source(
received.to_echo(),
"did:pkh:eip155:42161:0xabc",
PaymentPayload::hash("0xtxhash_roundtrip"),
);
let mut params = json!({"name": "premium_tool", "arguments": {"query": "test"}});
attach_credential(&mut params, &credential);
assert!(params["_meta"][CREDENTIAL_META_KEY].is_object());
let meta = params.get("_meta").unwrap();
let extracted = extract_credential(meta).unwrap();
assert_eq!(extracted.challenge.id, challenge.id);
assert_eq!(extracted.challenge.realm, "api.example.com");
assert_eq!(
extracted.source.as_deref(),
Some("did:pkh:eip155:42161:0xabc")
);
let echoed_challenge = PaymentChallenge {
id: extracted.challenge.id.clone(),
realm: extracted.challenge.realm.clone(),
method: extracted.challenge.method.clone(),
intent: extracted.challenge.intent.clone(),
request: extracted.challenge.request.clone(),
expires: None,
description: None,
digest: None,
opaque: None,
};
assert!(echoed_challenge.verify(secret));
let receipt = Receipt::success("tempo", "0xtxhash_roundtrip");
let mut result = json!({"content": [{"type": "text", "text": "paid response"}]});
attach_receipt(&mut result, &receipt, &challenge.id);
let receipt_value = &result["_meta"][RECEIPT_META_KEY];
assert_eq!(receipt_value["status"], "success");
assert_eq!(receipt_value["method"], "tempo");
assert_eq!(receipt_value["reference"], "0xtxhash_roundtrip");
assert_eq!(receipt_value["challengeId"], challenge.id);
assert_eq!(result["content"][0]["text"], "paid response");
let mcp_receipt: McpReceipt = serde_json::from_value(receipt_value.clone()).unwrap();
assert_eq!(mcp_receipt.challenge_id, challenge.id);
assert!(mcp_receipt.receipt.is_success());
}
#[test]
fn test_verification_failed_code_not_payment_required() {
let error = json!({
"code": PAYMENT_VERIFICATION_FAILED_CODE,
"message": "Payment Verification Failed",
"data": {
"httpStatus": 403,
"reason": "invalid signature"
}
});
assert!(!is_payment_required(&error));
assert!(extract_challenges(&error).is_none());
}
}