1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
//! Wire types for just-in-time egress grants (#1907).
//!
//! The runtime's egress proxy broadcasts [`EgressDenied`] when a scoped MCP
//! server is refused a host. The event is data only — no rendering hints — so
//! any client on the agent socket (murmur TUI, plain mode, Commander) can
//! render it its own way (spec D2). [`GRANT_METHOD`] is how a client tells the
//! running proxy to let a host through without a restart.
use serde::{Deserialize, Serialize};
/// JSON-RPC notification method for a denied egress connection.
pub const DENIED_METHOD: &str = "egress/denied";
/// A2A method that adds a host to a running proxy's policy.
pub const GRANT_METHOD: &str = "egress/grant";
/// At most one [`EgressDenied`] per `(server, host)` in this window.
pub const DENIED_EVENT_WINDOW_SECS: u64 = 60;
/// Why the proxy refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DenyReason {
/// `Restricted`: the host is not on the allow-list. Grantable.
NotAllowed,
/// `BroadAudited`: the host is on the deny list. Never grantable (D3).
DenyListed,
}
/// `params` of a [`DENIED_METHOD`] notification. The denied connection has
/// already failed; a grant affects only later connections.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EgressDenied {
pub agent: String,
pub server: String,
pub host: String,
pub port: Option<u16>,
/// The server's network mode, as stored (`restricted` / `broad_audited`).
pub mode: String,
pub reason: DenyReason,
/// RFC 3339 timestamp.
pub at: String,
}
impl EgressDenied {
/// Whether a client may offer a grant for this denial (D3).
pub fn grantable(&self) -> bool {
self.reason == DenyReason::NotAllowed
}
}
/// `params` of [`GRANT_METHOD`]. `session: true` lives in memory until the
/// agent restarts; `false` mirrors a grant the caller already wrote to the
/// profile, so it survives restart because the profile does.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EgressGrant {
pub server: String,
pub host: String,
#[serde(default)]
pub session: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn denied_round_trips_and_only_allow_list_misses_are_grantable() {
let d = EgressDenied {
agent: "mur".into(),
server: "browser".into(),
host: "img.shop.example.com".into(),
port: Some(443),
mode: "restricted".into(),
reason: DenyReason::NotAllowed,
at: "2026-10-15T00:00:00Z".into(),
};
let v = serde_json::to_value(&d).unwrap();
assert_eq!(v["reason"], "not_allowed");
assert_eq!(serde_json::from_value::<EgressDenied>(v).unwrap(), d);
assert!(d.grantable());
let listed = EgressDenied {
reason: DenyReason::DenyListed,
..d
};
assert!(!listed.grantable());
}
}