|
2 | 2 |
|
3 | 3 | import json |
4 | 4 | from collections.abc import Mapping |
| 5 | +from typing import Optional |
5 | 6 | from urllib.parse import urlparse |
6 | 7 |
|
7 | 8 | from fastapi import Request |
8 | 9 |
|
| 10 | +import constants |
9 | 11 | from configuration import AppConfig |
10 | 12 | from log import get_logger |
11 | 13 | from models.config import ModelContextProtocolServer |
@@ -121,3 +123,132 @@ def extract_propagated_headers( |
121 | 123 | if value is not None: |
122 | 124 | propagated[header_name] = value |
123 | 125 | return propagated |
| 126 | + |
| 127 | + |
| 128 | +def find_unresolved_auth_headers( |
| 129 | + configured: Mapping[str, str], |
| 130 | + resolved: Mapping[str, str], |
| 131 | +) -> list[str]: |
| 132 | + """Return configured auth header names that are absent from the resolved headers. |
| 133 | +
|
| 134 | + Comparison is case-insensitive so that ``Authorization`` and ``authorization`` |
| 135 | + are treated as the same header name. |
| 136 | +
|
| 137 | + Args: |
| 138 | + configured: The server's ``authorization_headers`` configuration mapping |
| 139 | + (header name → secret path or keyword). |
| 140 | + resolved: The fully resolved headers that will be sent to the MCP server. |
| 141 | +
|
| 142 | + Returns: |
| 143 | + List of header names from ``configured`` that could not be resolved, i.e. |
| 144 | + are not present as a key in ``resolved``. An empty list means all headers |
| 145 | + were resolved successfully. |
| 146 | + """ |
| 147 | + resolved_lower = {k.lower() for k in resolved} |
| 148 | + return [h for h in configured if h.lower() not in resolved_lower] |
| 149 | + |
| 150 | + |
| 151 | +def build_server_headers( |
| 152 | + mcp_server: ModelContextProtocolServer, |
| 153 | + client_headers: dict[str, str], |
| 154 | + request_headers: Optional[Mapping[str, str]], |
| 155 | + token: Optional[str], |
| 156 | +) -> dict[str, str]: |
| 157 | + """Build the complete set of headers for a single MCP server. |
| 158 | +
|
| 159 | + Merges client-supplied headers, resolved authorization headers, and propagated |
| 160 | + request headers in priority order (highest first): |
| 161 | +
|
| 162 | + 1. Client-supplied headers (already present in ``client_headers``). |
| 163 | + 2. Statically resolved authorization headers from configuration. |
| 164 | + 3. Kubernetes Bearer token for headers configured with the ``kubernetes`` keyword. |
| 165 | + ``client`` and ``oauth`` keywords are skipped — those values are already |
| 166 | + provided by the client in source 1. |
| 167 | + 4. Headers propagated from the incoming request via the server's allowlist. |
| 168 | +
|
| 169 | + Args: |
| 170 | + mcp_server: MCP server configuration. |
| 171 | + client_headers: Headers already supplied by the client for this server. |
| 172 | + request_headers: Headers from the incoming HTTP request, or ``None``. |
| 173 | + token: Optional Kubernetes service-account token. |
| 174 | +
|
| 175 | + Returns: |
| 176 | + Merged headers dictionary for the server. May be empty if no headers apply. |
| 177 | + """ |
| 178 | + server_headers: dict[str, str] = dict(client_headers) |
| 179 | + existing_lower = {k.lower() for k in server_headers} |
| 180 | + |
| 181 | + for ( |
| 182 | + header_name, |
| 183 | + resolved_value, |
| 184 | + ) in mcp_server.resolved_authorization_headers.items(): |
| 185 | + if header_name.lower() in existing_lower: |
| 186 | + continue |
| 187 | + match resolved_value: |
| 188 | + case constants.MCP_AUTH_KUBERNETES: |
| 189 | + if token: |
| 190 | + server_headers[header_name] = f"Bearer {token}" |
| 191 | + existing_lower.add(header_name.lower()) |
| 192 | + case constants.MCP_AUTH_CLIENT | constants.MCP_AUTH_OAUTH: |
| 193 | + pass # client-provided; already included via the initial client_headers copy |
| 194 | + case _: |
| 195 | + server_headers[header_name] = resolved_value |
| 196 | + existing_lower.add(header_name.lower()) |
| 197 | + |
| 198 | + if mcp_server.headers and request_headers is not None: |
| 199 | + propagated = extract_propagated_headers(mcp_server, request_headers) |
| 200 | + for h_name, h_value in propagated.items(): |
| 201 | + if h_name.lower() not in existing_lower: |
| 202 | + server_headers[h_name] = h_value |
| 203 | + existing_lower.add(h_name.lower()) |
| 204 | + |
| 205 | + return server_headers |
| 206 | + |
| 207 | + |
| 208 | +def build_mcp_headers( |
| 209 | + config: AppConfig, |
| 210 | + mcp_headers: McpHeaders, |
| 211 | + request_headers: Optional[Mapping[str, str]], |
| 212 | + token: Optional[str] = None, |
| 213 | +) -> McpHeaders: |
| 214 | + """Build complete MCP headers by merging all header sources for each MCP server. |
| 215 | +
|
| 216 | + For each configured MCP server, combines four header sources (in priority order, |
| 217 | + highest first): |
| 218 | +
|
| 219 | + 1. Client-supplied headers from the ``MCP-HEADERS`` request header (keyed by server name). |
| 220 | + 2. Statically resolved authorization headers from configuration (e.g. file-based secrets). |
| 221 | + 3. Kubernetes Bearer token: when a header is configured with the ``kubernetes`` keyword, |
| 222 | + the supplied ``token`` is formatted as ``Bearer <token>`` and used as its value. |
| 223 | + ``client`` and ``oauth`` keywords are not resolved here — those values are already |
| 224 | + provided by the client in source 1. |
| 225 | + 4. Headers propagated from the incoming request via the server's configured allowlist. |
| 226 | +
|
| 227 | + Args: |
| 228 | + config: Application configuration containing mcp_servers. |
| 229 | + mcp_headers: Per-request headers from the client, keyed by MCP server name. |
| 230 | + request_headers: Headers from the incoming HTTP request used for allowlist |
| 231 | + propagation, or ``None`` when not available. |
| 232 | + token: Optional Kubernetes service-account token used to resolve headers |
| 233 | + configured with the ``kubernetes`` keyword. |
| 234 | +
|
| 235 | + Returns: |
| 236 | + McpHeaders keyed by MCP server name with the complete merged set of headers. |
| 237 | + Servers that end up with no headers are omitted from the result. |
| 238 | + """ |
| 239 | + if not config.mcp_servers: |
| 240 | + return {} |
| 241 | + |
| 242 | + complete: McpHeaders = {} |
| 243 | + |
| 244 | + for mcp_server in config.mcp_servers: |
| 245 | + server_headers = build_server_headers( |
| 246 | + mcp_server, |
| 247 | + dict(mcp_headers.get(mcp_server.name, {})), |
| 248 | + request_headers, |
| 249 | + token, |
| 250 | + ) |
| 251 | + if server_headers: |
| 252 | + complete[mcp_server.name] = server_headers |
| 253 | + |
| 254 | + return complete |
0 commit comments