For more information regarding the security incident at F5, the actions we are taking to address it, and our ongoing efforts to protect our customers, click here.

Managing Model Context Protocol in iRules - Part 2

In the first article in this series, we took a look at what Model Context Protocol (MCP) is, and how to get the F5 BIG-IP set up to manage it with iRules. In this article, we'll take a look at the first couple of use cases with session persistence and routing. Note that the use cases in this article do not require the json or sse profiles to work. That will change in part 3.

Session persistence and routing

This iRule ensures session persistence and traffic routing for three endpoints: /sse, /messages, and /mcp. It injects routing information (f5Session) via query parameters or headers, processes them for routing to specific pool members, and transparently forwards requests to the server.

How it works

  1. Client sends HTTP GET request to SSE endpoint of server (typically /sse):
    GET /sse HTTP/1.1
    
  2. Server responds 200 OK with an SSE event stream. It includes an SSE message with an "event" field of "endpoint", which provides the client with a URI where all its future HTTP requests must be sent. This is where servers might include a session ID:
    event: endpoint data: /messages?sessionId=abcd1234efgh5678

     

    NOTE: the MCP spec does not specify how a session ID can be encoded in the endpoint here. While we have only seen use of a sessionId query parameter, theoretically a server could implement its session Ids with any arbitrary query parameter name, or even as part of the path like this:

     

    event: endpoint data: /messages/abcd1234efgh5678
  3. Our iRule can take advantage of this mechanism by injecting a query parameter into this path that tells us which server we should persist future requests to. So when we forward the SSE message to the client, it looks something like this:
    event: endpoint data: /messages?f5Session=some_pool_name,10.10.10.5:8080&sessionId=abcd1234efgh5678

    or

    event: endpoint data: /messages/abcd1234efgh5678?f5Session=some_pool_name,10.10.10.5:8080
  4. When the client sends a subsequent HTTP request, it will use this endpoint. Thus, when processing HTTP requests, we can read the f5Session secret we inserted earlier, route to that pool member, and then remove our secret before forwarding the request to the server using the original endpoint/sessionId it provided.

Load Balancing

when HTTP_REQUEST {
    set is_req_to_sse_endpoint false
 
    # Handle requests to `/sse` (Server-Sent Event endpoint)
    if { [HTTP::path] eq "/sse" } {
        set is_req_to_sse_endpoint true
        return
    }
 
    # Handle `/messages` endpoint persistence query processing
    if { [HTTP::path] eq "/messages" } {
        set query_string [HTTP::query]
        set f5_sess_found false
        set new_query_string ""
        set query_separator ""
 
        set queries [split $query_string "&"]  ;# Split query string into individual key-value pairs
        foreach query $queries {
            if { $f5_sess_found } {
                append new_query_string "${query_separator}${query}"
                set query_separator "&"
            } elseif { [string match "f5Session=*" $query] } {
                # Parse `f5Session` for persistence routing
                set pmbr_info [string range $query 10 end]
                set pmbr_parts [split $pmbr_info ","]
                if { [llength $pmbr_parts] == 2 } {
                    set pmbr_tuple [split [lindex $pmbr_parts 1] ":"]
                    if { [llength $pmbr_tuple] == 2 } {
                        pool [lindex $pmbr_parts 0] member [lindex $pmbr_parts 1]
                        set f5_sess_found true
                    } else {
                        HTTP::respond 404 noserver
                        return
                    }
                } else {
                    HTTP::respond 404 noserver
                    return
                }
            } else {
                append new_query_string "${query_separator}${query}"
                set query_separator "&"
            }
        }
 
        if { $f5_sess_found } {
            HTTP::query $new_query_string
        } else {
            HTTP::respond 404 noserver
        }
        return
    }
 
    # Handle `/mcp` endpoint persistence via session header
    if { [HTTP::path] eq "/mcp" } {
        if { [HTTP::header exists "Mcp-Session-Id"] } {
            set header_value [HTTP::header "Mcp-Session-Id"]
            set header_parts [split $header_value ","]
            if { [llength $header_parts] == 3 } {
                set pmbr_tuple [split [lindex $header_parts 1] ":"]
                if { [llength $pmbr_tuple] == 2 } {
                    pool [lindex $header_parts 0] member [lindex $header_parts 1]
                    HTTP::header replace "Mcp-Session-Id" [lindex $header_parts 2]
                } else {
                    HTTP::respond 404 noserver
                }
            } else {
                HTTP::respond 404 noserver
            }
        }
    }
}
 
when HTTP_RESPONSE {
    # Persist session for MCP responses
    if { [HTTP::header exists "Mcp-Session-Id"] } {
        set pool_member [LB::server pool],[IP::remote_addr]:[TCP::remote_port]
        set header_value [HTTP::header "Mcp-Session-Id"]
        set new_header_value "$pool_member,$header_value"
        HTTP::header replace "Mcp-Session-Id" $new_header_value
    }
 
    # Inject persistence information into response payloads for Server-Sent Events
    if { $is_req_to_sse_endpoint } {
        set sse_data [HTTP::payload]  ;# Get the SSE payload
        # Extract existing query params from the SSE response
        set old_queries [URI::query $sse_data]
        if { [string length $old_queries] == 0 } {
            set query_separator ""
        } else {
            set query_separator "&"
        }
        # Insert `f5Session` persistence information into query
        set new_query "f5Session=[URI::encode [LB::server pool],[IP::remote_addr]:[TCP::remote_port]]"
        set new_payload "?${new_query}${query_separator}${old_queries}"
 
        # Replace the payload in the SSE response
        HTTP::payload replace 0 [string length $sse_data] $new_payload
    }
}

Persistence

when CLIENT_ACCEPTED {
    # Log when a new TCP connection arrives (useful for debugging)
    log local0. "New TCP connection accepted from [IP::client_addr]:[TCP::client_port]"
}
 
when HTTP_REQUEST {
    # Check if this might be an SSE request by examining the Accept header
    if {[HTTP::header exists "Accept"] && [HTTP::header "Accept"] contains "text/event-stream"} {
        log local0. "SSE Request detected from [IP::client_addr] to [HTTP::uri]"
        # Insert a custom persistence key (optional)
        set sse_persistence_key "[IP::client_addr]:[HTTP::uri]"
        persist uie $sse_persistence_key
    }
}
 
when HTTP_RESPONSE {
    # Ensure this is an SSE connection by checking the Content-Type
    if {[HTTP::header exists "Content-Type"] && [HTTP::header "Content-Type"] equals "text/event-stream"} {
        log local0. "SSE Response detected for [IP::client_addr]. Enabling persistence."
        # Use the same persistence key for the response
        persist add uie $sse_persistence_key
    }
}

Conclusion

Thank you for your patience! Now is the time to continue on to part 3 where we'll finally get into the new JSON commands and events added in version 21!

NOTE: This series is ghostwritten. Awaiting permission from original author to credit.

Published Nov 18, 2025
Version 1.0
No CommentsBe the first to comment