Module: Mpp::Extensions::MCP

Extended by:
T::Sig
Defined in:
lib/mpp/extensions/mcp.rb,
lib/mpp/extensions/mcp/types.rb,
lib/mpp/extensions/mcp/errors.rb,
lib/mpp/extensions/mcp/verify.rb,
lib/mpp/extensions/mcp/constants.rb,
lib/mpp/extensions/mcp/decorator.rb,
lib/mpp/extensions/mcp/capabilities.rb

Defined Under Namespace

Classes: MCPChallenge, MCPCredential, MCPReceipt, MalformedCredentialError, PaymentRequiredError, PaymentVerificationError

Constant Summary collapse

DEFAULT_CHALLENGE_TTL =

5 minutes in seconds

T.let(5 * 60, Integer)
META_CREDENTIAL =
"org.paymentauth/credential"
META_RECEIPT =
"org.paymentauth/receipt"
CODE_PAYMENT_REQUIRED =
-32_042
CODE_PAYMENT_VERIFICATION_FAILED =
-32_043
CODE_MALFORMED_CREDENTIAL =
-32_602
HTTP_STATUS_PAYMENT_REQUIRED =
402

Class Method Summary collapse

Class Method Details

.create_challenge(method:, intent_name:, request:, realm:, secret_key:, expires_in: DEFAULT_CHALLENGE_TTL, description: nil) ⇒ Object



117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/mpp/extensions/mcp/verify.rb', line 117

def create_challenge(method:, intent_name:, request:, realm:, secret_key:,
  expires_in: DEFAULT_CHALLENGE_TTL, description: nil)
  expires_time = Time.now.utc + expires_in
  expires = expires_time.iso8601
  expires = expires.sub(/\+00:00$/, "Z")

  challenge_id = Mpp.generate_challenge_id(
    secret_key: secret_key,
    realm: realm,
    method: method,
    intent: intent_name,
    request: request,
    expires: expires
  )

  MCPChallenge.new(
    id: challenge_id,
    realm: realm,
    method: method,
    intent: intent_name,
    request: request,
    expires: expires,
    description: description
  )
end

.extract_settlement(request) ⇒ Object



144
145
146
147
148
149
# File 'lib/mpp/extensions/mcp/verify.rb', line 144

def extract_settlement(request)
  settlement = {}
  settlement["amount"] = request["amount"] if request.key?("amount")
  settlement["currency"] = request["currency"] if request.key?("currency")
  settlement.empty? ? nil : settlement
end

.pay_tool(intent:, request:, meta:, realm: nil, secret_key: nil, method: nil, expires_in: DEFAULT_CHALLENGE_TTL, description: nil) {|credential, receipt| ... } ⇒ Object

Yields:

  • (credential, receipt)


19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/mpp/extensions/mcp/decorator.rb', line 19

def pay_tool(intent:, request:, meta:, realm: nil, secret_key: nil,
  method: nil, expires_in: DEFAULT_CHALLENGE_TTL, description: nil, &blk)
  resolved_realm = realm || Mpp::Server::Defaults.detect_realm
  resolved_secret_key = secret_key || Mpp::Server::Defaults.detect_secret_key

  request_params = request.respond_to?(:call) ? request.call : request

  result = verify_or_challenge(
    meta: meta,
    intent: intent,
    request: request_params,
    realm: resolved_realm,
    secret_key: resolved_secret_key,
    method: method,
    expires_in: expires_in,
    description: description
  )

  Kernel.raise PaymentRequiredError.new(challenges: [result]) if result.is_a?(MCPChallenge)

  credential, receipt = result
  yield credential, receipt
end

.payment_capabilities(methods, intents) ⇒ Object



13
14
15
16
17
18
19
20
# File 'lib/mpp/extensions/mcp/capabilities.rb', line 13

def payment_capabilities(methods, intents)
  {
    "payment" => {
      "methods" => methods,
      "intents" => intents
    }
  }
end

.verify_or_challenge(meta:, intent:, request:, realm:, secret_key:, method: nil, expires_in: DEFAULT_CHALLENGE_TTL, description: nil) ⇒ Object



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/mpp/extensions/mcp/verify.rb', line 18

def verify_or_challenge(meta:, intent:, request:, realm:, secret_key:,
  method: nil, expires_in: DEFAULT_CHALLENGE_TTL, description: nil)
  method_name = method || "tempo"
  meta ||= {}

  new_challenge = Kernel.lambda {
    create_challenge(
      method: method_name,
      intent_name: intent.name,
      request: request,
      realm: realm,
      secret_key: secret_key,
      expires_in: expires_in,
      description: description
    )
  }

  credential_data = meta[META_CREDENTIAL]
  return new_challenge.call unless credential_data

  begin
    mcp_credential = MCPCredential.from_dict(credential_data)
  rescue KeyError, TypeError, NoMethodError => e
    Kernel.raise MalformedCredentialError.new(detail: "Invalid credential structure: #{e}")
  end

  # Stateless challenge verification
  echoed = mcp_credential.challenge
  expected_id = Mpp.generate_challenge_id(
    secret_key: secret_key,
    realm: echoed.realm,
    method: echoed.method,
    intent: echoed.intent,
    request: echoed.request,
    expires: echoed.expires,
    digest: echoed.digest,
    opaque: echoed.opaque
  )
  return new_challenge.call unless Mpp.secure_compare(echoed.id, expected_id)

  # Assert echoed fields match server's values
  unless echoed.realm == realm && echoed.method == method_name && echoed.intent == intent.name
    return new_challenge.call
  end

  # Assert echoed request matches server's current request
  return new_challenge.call unless echoed.request == request

  # Reject expired challenges as defense-in-depth
  if echoed.expires
    begin
      expires_dt = Time.iso8601(echoed.expires.gsub("Z", "+00:00"))
      return new_challenge.call if expires_dt < Time.now.utc
    rescue ArgumentError
      # continue to stricter check
    end
  end

  # Verify echoed request parameters
  echoed_request = echoed.request.is_a?(Hash) ? echoed.request : {}
  request.each do |key, value|
    next if key == "expires"

    return new_challenge.call unless echoed_request[key] == value
  end

  # Enforce challenge expiry - fail closed
  return new_challenge.call unless echoed.expires

  begin
    expires_dt = Time.iso8601(echoed.expires.gsub("Z", "+00:00"))
  rescue ArgumentError
    return new_challenge.call
  end
  return new_challenge.call if expires_dt < Time.now.utc

  core_credential = mcp_credential.to_core

  begin
    core_receipt = intent.verify(core_credential, request)
  rescue Mpp::VerificationError => e
    Kernel.raise PaymentVerificationError.new(
      challenges: [new_challenge.call],
      reason: "verification-failed",
      detail: e.message
    )
  end

  mcp_receipt = MCPReceipt.from_core(
    core_receipt,
    challenge_id: mcp_credential.challenge.id,
    method: mcp_credential.challenge.method,
    settlement: extract_settlement(request)
  )

  [mcp_credential, mcp_receipt]
end