0027 — Con HTTP la credencial del cliente llega por cabecera y **por petición**
Estado: Vigente
Contexto
En S2 el servidor MCP hablaba stdio, y GatewayHandlers —el tipo que la pasarela le pasa al adaptador de entrada— no llevaba credencial: la cáscara cerraba sobre una función que leía process.env en cada petición. Funcionaba porque con stdio el cliente arranca el proceso, así que hay un proceso por cliente y la identidad es del proceso.
Con HTTP hay un proceso para todos. Dos clientes con identidades distintas llegan por el mismo puerto, y su credencial viene en la cabecera de su petición.
Decisión
GatewayHandlers recibe la credencial como argumento, en las dos operaciones:
listTools(credentials)
callTool(credentials, name, args)
El servidor de stdio se construye con un proveedor que se evalúa en cada petición y lee el entorno; el de HTTP la extrae de la cabecera de la petición que está atendiendo. Y cada petición HTTP se atiende con su propio servidor MCP, sin sesión.
runtime/src/ports.ts no cambia: Gateway.list y Gateway.call ya recibían las credenciales por llamada desde S2.
Motivo
Por qué no vale una clausura. Un servidor HTTP que cerrara sobre una credencial fija atendería a todo el mundo con la identidad de quien la fijó al arrancar. Eso no es un problema de fontanería: es un fallo de autorización, del tipo que no da error y sirve datos de más. La forma del tipo tiene que hacerlo imposible, y un argumento lo hace: no hay dónde guardar una identidad entre peticiones.
Por qué un servidor MCP por petición. Es el modo que el SDK llama stateless, y refuerza lo mismo por otra vía: sin estado compartido entre peticiones, la identidad de una no puede filtrarse a la siguiente ni por descuido.
Por qué esto no rompe la promesa de S3. GatewayHandlers es un tipo de adapters/, no un puerto. La prueba está en el retrato: verification/surface/runtime.d.ts no lo menciona, y no ha cambiado. El contrato que sí importa —Gateway— ya estaba bien planteado desde S2, y esa es la comprobación que S3 existía para hacer: los dos transportes caben en él sin tocarlo.
Que la ausencia de cabecera se traduzca a cadena vacía y no a un 401 del transporte es coherente con el invariante 3. Quien decide qué significa “sin credencial” es PrincipalResolver, y contestar antes convertiría una denegación explicable —con su código y su sitio en el artefacto— en un 401 mudo.
Consecuencias
mcp-stdio-server.tsymcp-http-server.tscomparten todo menos el transporte: los dos manejadores viven enmcp-server.ts. Que el segundo no tuviera que reimplementar el primero es la señal de que el corte de la decisión 0015 estaba bien puesto.runtime/src/cli/main.tspasa de una clausura a un objeto de manejadores que sirve a los dos servidores. Es la única forma en que este cambio llega aruntime, y es la cáscara.- La cabecera es configurable con
--key-header; por defectoAuthorization, y se le quita el prefijoBearer. - Con
--httpel proceso no termina cuando un cliente se va: los clientes van y vienen. Termina cuando se le manda terminar. - Falta el otro lado de una pasarela HTTP seria: TLS, CORS y límites de tamaño de petición. Es empaquetado y operación, que es S4.
Alternativas descartadas
- Mantener
GatewayHandlerssin credencial y llevarla porAsyncLocalStorage. No toca ningún tipo y hace invisible en la firma la cosa que más importa que se vea. Un estado implícito por petición es el sitio donde se escriben los fallos de autorización difíciles de encontrar. - Un servidor MCP compartido entre peticiones, en modo con sesión. Ata la identidad a la sesión MCP y no a la petición, y obliga a decidir qué pasa cuando dos credenciales distintas reusan una sesión.
- Contestar 401 en el transporte cuando falta la cabecera. Se pierde el motivo y el sitio del artefacto: el bucle de corrección del invariante 4 deja de funcionar justo en el borde exterior.
Importado de rflvz/mcpizer en bf25803. El cuerpo no se reescribe: sólo se reenlaza.