11use axum:: {
2- extract:: Query ,
3- http:: { header, StatusCode } ,
2+ extract:: { Query , State } ,
3+ http:: { header, HeaderValue , StatusCode } ,
44 response:: { Html , IntoResponse , Response } ,
55 routing:: { get, post} ,
66 Json , Router ,
77} ;
8- use serde:: Deserialize ;
8+ use serde:: { Deserialize , Serialize } ;
99use std:: collections:: HashMap ;
1010use std:: sync:: Arc ;
11+ use std:: time:: Duration ;
1112use std:: time:: Instant ;
1213use tokio:: sync:: RwLock ;
14+ use url:: Url ;
1315use uuid:: Uuid ;
1416
1517const GUEST_HTML_TTL_SECS : u64 = 300 ;
1618const GUEST_HTML_MAX_ENTRIES : usize = 64 ;
1719const MCP_APP_PROXY_HTML : & str = include_str ! ( "templates/mcp_app_proxy.html" ) ;
1820
19- type GuestHtmlStore = Arc < RwLock < HashMap < String , ( String , String , Instant ) > > > ;
21+ type GuestHtmlStore = Arc < RwLock < HashMap < String , GuestHtmlEntry > > > ;
22+
23+ #[ derive( Clone ) ]
24+ struct GuestHtmlEntry {
25+ html : String ,
26+ csp : String ,
27+ created : Instant ,
28+ }
2029
2130#[ derive( Deserialize ) ]
2231struct ProxyQuery {
@@ -30,7 +39,6 @@ struct ProxyQuery {
3039
3140#[ derive( Deserialize ) ]
3241struct GuestQuery {
33- secret : String ,
3442 nonce : String ,
3543}
3644
@@ -41,19 +49,49 @@ struct StoreGuestBody {
4149 csp : Option < String > ,
4250}
4351
52+ #[ derive( Serialize ) ]
53+ #[ serde( rename_all = "camelCase" ) ]
54+ struct StoreGuestResponse {
55+ nonce : String ,
56+ guest_url : String ,
57+ }
58+
4459#[ derive( Clone ) ]
4560struct AppState {
4661 secret_key : String ,
4762 guest_store : GuestHtmlStore ,
63+ guest_base_url : String ,
64+ }
65+
66+ #[ derive( Clone ) ]
67+ struct GuestState {
68+ guest_store : GuestHtmlStore ,
69+ }
70+
71+ fn normalize_csp_source ( source : & str ) -> Option < String > {
72+ let source = source. trim ( ) ;
73+ if source. is_empty ( )
74+ || source
75+ . chars ( )
76+ . any ( |c| c. is_ascii_whitespace ( ) || matches ! ( c, ';' | ',' | '"' | '\'' ) )
77+ {
78+ return None ;
79+ }
80+
81+ let url = Url :: parse ( source) . ok ( ) ?;
82+ if !matches ! ( url. scheme( ) , "http" | "https" | "ws" | "wss" ) {
83+ return None ;
84+ }
85+ url. host_str ( ) ?;
86+ Some ( url. origin ( ) . ascii_serialization ( ) )
4887}
4988
5089fn parse_domains ( domains : Option < & String > ) -> Vec < String > {
5190 domains
5291 . map ( |domains| {
5392 domains
5493 . split ( ',' )
55- . map ( |domain| domain. trim ( ) . to_string ( ) )
56- . filter ( |domain| !domain. is_empty ( ) )
94+ . filter_map ( normalize_csp_source)
5795 . collect ( )
5896 } )
5997 . unwrap_or_default ( )
@@ -65,6 +103,7 @@ fn build_outer_csp(
65103 frame_domains : & [ String ] ,
66104 base_uri_domains : & [ String ] ,
67105 script_domains : & [ String ] ,
106+ guest_origin : & str ,
68107) -> String {
69108 let resources = if resource_domains. is_empty ( ) {
70109 String :: new ( )
@@ -85,9 +124,12 @@ fn build_outer_csp(
85124 } ;
86125
87126 let frame_src = if frame_domains. is_empty ( ) {
88- "frame-src 'self'" . to_string ( )
127+ format ! ( "frame-src 'self' {guest_origin}" )
89128 } else {
90- format ! ( "frame-src 'self' {}" , frame_domains. join( " " ) )
129+ format ! (
130+ "frame-src 'self' {guest_origin} {}" ,
131+ frame_domains. join( " " )
132+ )
91133 } ;
92134
93135 let base_uris = if base_uri_domains. is_empty ( ) {
@@ -113,7 +155,7 @@ fn build_outer_csp(
113155}
114156
115157async fn mcp_app_proxy (
116- axum :: extract :: State ( state) : axum :: extract :: State < AppState > ,
158+ State ( state) : State < AppState > ,
117159 Query ( params) : Query < ProxyQuery > ,
118160) -> Response {
119161 if params. secret != state. secret_key {
@@ -128,6 +170,7 @@ async fn mcp_app_proxy(
128170 & parse_domains ( params. frame_domains . as_ref ( ) ) ,
129171 & parse_domains ( params. base_uri_domains . as_ref ( ) ) ,
130172 & parse_domains ( params. script_domains . as_ref ( ) ) ,
173+ & state. guest_base_url ,
131174 ) ,
132175 ) ;
133176
@@ -145,7 +188,7 @@ async fn mcp_app_proxy(
145188}
146189
147190async fn store_guest_html (
148- axum :: extract :: State ( state) : axum :: extract :: State < AppState > ,
191+ State ( state) : State < AppState > ,
149192 Json ( body) : Json < StoreGuestBody > ,
150193) -> Response {
151194 if body. secret != state. secret_key {
@@ -154,76 +197,109 @@ async fn store_guest_html(
154197
155198 let nonce = Uuid :: new_v4 ( ) . to_string ( ) ;
156199 let csp = body. csp . unwrap_or_default ( ) ;
200+ let guest_url = format ! ( "{}/mcp-app-guest?nonce={}" , state. guest_base_url, nonce) ;
157201
158202 {
159203 let mut store = state. guest_store . write ( ) . await ;
160- let cutoff = Instant :: now ( ) - std :: time :: Duration :: from_secs ( GUEST_HTML_TTL_SECS ) ;
161- store. retain ( |_, ( _ , _ , created ) | * created > cutoff) ;
204+ let cutoff = Instant :: now ( ) - Duration :: from_secs ( GUEST_HTML_TTL_SECS ) ;
205+ store. retain ( |_, entry| entry . created > cutoff) ;
162206
163207 if store. len ( ) >= GUEST_HTML_MAX_ENTRIES {
164208 if let Some ( oldest_key) = store
165209 . iter ( )
166- . min_by_key ( |( _, ( _ , _ , created ) ) | * created)
210+ . min_by_key ( |( _, entry ) | entry . created )
167211 . map ( |( key, _) | key. clone ( ) )
168212 {
169213 store. remove ( & oldest_key) ;
170214 }
171215 }
172216
173- store. insert ( nonce. clone ( ) , ( body. html , csp, Instant :: now ( ) ) ) ;
217+ store. insert (
218+ nonce. clone ( ) ,
219+ GuestHtmlEntry {
220+ html : body. html ,
221+ csp,
222+ created : Instant :: now ( ) ,
223+ } ,
224+ ) ;
174225 }
175226
176227 (
177228 StatusCode :: OK ,
178- [ ( header:: CONTENT_TYPE , "application/json" ) ] ,
179- format ! ( r#"{{"nonce":"{}"}}"# , nonce) ,
229+ Json ( StoreGuestResponse { nonce, guest_url } ) ,
180230 )
181231 . into_response ( )
182232}
183233
184234async fn serve_guest_html (
185- axum :: extract :: State ( state) : axum :: extract :: State < AppState > ,
235+ State ( state) : State < GuestState > ,
186236 Query ( params) : Query < GuestQuery > ,
187237) -> Response {
188- if params. secret != state. secret_key {
189- return ( StatusCode :: UNAUTHORIZED , "Unauthorized" ) . into_response ( ) ;
190- }
191-
192238 let entry = {
193239 let mut store = state. guest_store . write ( ) . await ;
194- store. remove ( & params. nonce )
240+ let cutoff = Instant :: now ( ) - Duration :: from_secs ( GUEST_HTML_TTL_SECS ) ;
241+ store. retain ( |_, entry| entry. created > cutoff) ;
242+ store. get ( & params. nonce ) . cloned ( )
195243 } ;
196244
197245 match entry {
198- Some ( ( html , csp , _created ) ) => {
199- let mut response = Html ( html) . into_response ( ) ;
246+ Some ( entry ) => {
247+ let mut response = Html ( entry . html ) . into_response ( ) ;
200248 let headers = response. headers_mut ( ) ;
201249 headers. insert (
202250 header:: HeaderName :: from_static ( "referrer-policy" ) ,
203251 "strict-origin" . parse ( ) . unwrap ( ) ,
204252 ) ;
205- if !csp. is_empty ( ) {
206- headers. insert ( header:: CONTENT_SECURITY_POLICY , csp. parse ( ) . unwrap ( ) ) ;
253+ if !entry. csp . is_empty ( ) {
254+ match HeaderValue :: from_str ( & entry. csp ) {
255+ Ok ( csp) => {
256+ headers. insert ( header:: CONTENT_SECURITY_POLICY , csp) ;
257+ }
258+ Err ( _) => return ( StatusCode :: BAD_REQUEST , "Invalid CSP" ) . into_response ( ) ,
259+ }
207260 }
208261 response
209262 }
210- None => (
211- StatusCode :: NOT_FOUND ,
212- "Guest content not found or already consumed" ,
213- )
214- . into_response ( ) ,
263+ None => ( StatusCode :: NOT_FOUND , "Guest content not found" ) . into_response ( ) ,
215264 }
216265}
217266
267+ fn spawn_guest_server ( guest_store : GuestHtmlStore ) -> String {
268+ let listener =
269+ std:: net:: TcpListener :: bind ( ( "127.0.0.1" , 0 ) ) . expect ( "failed to bind MCP app guest server" ) ;
270+ let addr = listener
271+ . local_addr ( )
272+ . expect ( "failed to read MCP app guest server address" ) ;
273+ listener
274+ . set_nonblocking ( true )
275+ . expect ( "failed to configure MCP app guest server" ) ;
276+ let listener = tokio:: net:: TcpListener :: from_std ( listener)
277+ . expect ( "failed to create MCP app guest listener" ) ;
278+
279+ let app = Router :: new ( )
280+ . route ( "/mcp-app-guest" , get ( serve_guest_html) )
281+ . with_state ( GuestState { guest_store } ) ;
282+
283+ tokio:: spawn ( async move {
284+ if let Err ( error) = axum:: serve ( listener, app) . await {
285+ tracing:: error!( %error, "MCP app guest server stopped" ) ;
286+ }
287+ } ) ;
288+
289+ format ! ( "http://{addr}" )
290+ }
291+
218292pub ( crate ) fn routes ( secret_key : String ) -> Router {
293+ let guest_store = Arc :: new ( RwLock :: new ( HashMap :: new ( ) ) ) ;
294+ let guest_base_url = spawn_guest_server ( guest_store. clone ( ) ) ;
219295 let state = AppState {
220296 secret_key,
221- guest_store : Arc :: new ( RwLock :: new ( HashMap :: new ( ) ) ) ,
297+ guest_store,
298+ guest_base_url,
222299 } ;
223300
224301 Router :: new ( )
225302 . route ( "/mcp-app-proxy" , get ( mcp_app_proxy) )
226- . route ( "/mcp-app-guest" , get ( serve_guest_html) )
227303 . route ( "/mcp-app-guest" , post ( store_guest_html) )
228304 . with_state ( state)
229305}
0 commit comments