Autenticación OAuth en un servidor MCP: el caso real de Sherpa Platform

Imagen generada por IA para el blog de Orbitant sobre autenticación OAuth en un servidor MCP, que muestra herramientas de precisión como llaves y calibres protegidas bajo una campana de cristal iluminada sobre fondo oscuro.

El inputSchema valida que un identificador tenga forma de UUID. No valida que ese UUID pertenezca al usuario que pregunta. Esa diferencia es la que separa un servidor MCP que funciona de uno que además es seguro.

LUCAS JIN
Software Engineer

Un servidor MCP con todas sus tools registradas y funcionando puede seguir siendo inseguro: nada en su diseño impide que cualquiera con la URL llame a cualquiera de ellas. La autenticación OAuth en un servidor MCP es la capa que decide quién puede hacer esa llamada, construida separada del código que registra cada herramienta, no repartida dentro de cada una. Así lo hicimos en el servidor real de Sherpa Platform, aplicando los fundamentos de Model Context Protocol (MCP) que explicó Arturo Gómez, Software Engineer en Orbitant, sobre la diferencia entre tools, resources y prompts en MCP.

Sherpa Platform es el servicio de gestión financiera externalizada que desarrollamos en Orbitant para un cliente y su servidor MCP expone datos como ventas o liquidez bancaria de la empresa que los consulta. Todo lo que el modelo ve de una tool (su nombre, su descripción, su esquema de argumentos) es una declaración, no una comprobación. La pregunta que de verdad importa es otra: quién decide qué puede pedir cada usuario y dónde vive esa decisión.

Lo que el modelo ve de una tool: descripción, no seguridad

Todo lo que el modelo sabe de una tool de Sherpa Platform se reduce a lo que declaramos en su registro: no lee el handler, no ve la base de datos, no sabe si detrás hay una validación real. Elige y llama a partir de tres piezas (description, inputSchema y annotations), texto y esquema, nada más. Confundir esa declaración con una barrera de seguridad fue el error que más nos costó erradicar al diseñar el catálogo. Antes de hablar de autenticación, conviene entender qué hace cada una de esas tres piezas por separado.

La description es la pieza con más impacto real: el modelo elige una herramienta a partir de esta cadena de texto y sin ella solo tiene el nombre para adivinar cuál se parece más a lo que necesita. El inputSchema es un esquema de Zod, la librería de validación con la que tipamos cada argumento: cada .describe() que escribimos ahí llega también al modelo, como en esta tool real de Sherpa Platform:

const inputSchema = {
  companyGroupId: z
    .uuid()
    .describe('Company group ID (from sherpa_list_company_groups)'),
  year: z.number().int().describe('Fiscal year'),
}

export const getProfitAndLossReportTool = {
  name: 'sherpa_get_profit_and_loss_report',
  config: {
    description:
      'Returns the main P&L report for a company group and fiscal year. Contains revenue, expenses, and profit breakdown with monthly values and YTD totals.',
    inputSchema,
    annotations: {
      readOnlyHint: true,
      destructiveHint: false,
      idempotentHint: true,
      openWorldHint: false,
    },
  },
}

El comentario (from sherpa_list_company_groups) dentro del .describe() es la razón entera por la que el modelo llama primero a esa tool: el encadenado de llamadas está escrito ahí, sin que nadie lo programe en ningún otro sitio.

Annotations

Las annotations fueron las que peor entendimos al principio, porque sus valores por defecto son pesimistas: quedarte callado presenta una consulta de solo lectura como una escritura destructiva, no idempotente y de mundo abierto. Por eso las declaramos las cuatro en cada herramienta, como se ve en el ejemplo anterior:

  • readOnlyHint: true: la tool solo lee, nunca modifica estado. Por defecto es false.
  • destructiveHint: false: si escribiera, el cambio no sería destructivo ni irreversible. Por defecto es true.
  • idempotentHint: true: llamarla otra vez con los mismos argumentos no tiene efecto adicional. Por defecto es false.
  • openWorldHint: false: mundo cerrado, la tool solo toca datos propios de Sherpa Platform. Por defecto es true.

Describir no protege

El inputSchema valida forma, tipo y restricciones (nunca permisos de negocio) y readOnlyHint: true no hace que el handler sea de solo lectura, porque nada inspecciona el código para comprobarlo. Todo lo que hay en config describe: el nombre, el texto, el esquema, las cuatro anotaciones. Quien decide de verdad qué pasa con cada llamada es el handler: ahí seguía sin resolver la pregunta que de verdad importaba, quién estaba llamando a la herramienta.

El servidor que construimos antes de pensar en quién llama

Diseñamos el servidor de Sherpa Platform en dos bloques deliberadamente separados: primero las tools, sin que ninguna supiera nada sobre quién las llamaba; después la autenticación, como una capa aparte. Esa separación no fue casualidad: nos permitió construir y probar el catálogo completo (seis herramientas, discutiendo nombres y descriptions) sin que la lógica de negocio se mezclara con la de acceso.

El endpoint y el catálogo de tools

El servidor vive en una única ruta, /api/mcp, servida por la función createMcpHandler que ofrece la librería mcp-handler:

createMcpHandler(
  registerMcpTools,
  { serverInfo: { name: 'sherpa', version: MCP_SERVER_VERSION } },
  { basePath: '/api' }
)

Recibe tres parámetros: initializeServer (la función donde registramos cada tool), serverOptions (la identidad que ve el cliente al conectarse, con una versión que subimos cada vez que cambia el catálogo) y config (la configuración de transporte, con el basePath). Dentro de initializeServer registramos cada herramienta con registerTool:

export const registerMcpTools = (server) => {
  server.registerTool(listCompanyGroupsTool.name, listCompanyGroupsTool.config, listCompanyGroupsTool.handler)
  server.registerTool(listBankProductsTool.name, listBankProductsTool.config, listBankProductsTool.handler)
  // … cuatro más
}

El nombre de cada tool fue lo que más nos costó interiorizar: get_bank_liquidity no significa nada fuera de nuestro servidor, porque el cliente MCP junta todas las herramientas de todos los servidores conectados en una sola lista plana antes de pasársela al modelo.

Con Sherpa Platform, un ERP y GitHub conectados a la vez, get_transactions choca con cualquier otra tool que se llame igual. Prefijar con sherpa_ (así queda sherpa_get_bank_liquidity) es la única versión que funciona siempre, la use o no el cliente que namespacea por servidor.

El callback y los errores que el modelo puede resolver

El callback es la única pieza de todo esto que de verdad ejecuta código. Así es una tool completa, de principio a fin:

export const getBankLiquidityTool = {
  name: 'sherpa_get_bank_liquidity',
  config: {
    description: TOOL_DESCRIPTION,
    inputSchema,
    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  },
  handler: async ({ companyGroupId }, extra) => {
    const authExtras = extra.authInfo?.extra
    // … buscar el grupo de empresa, obtener los productos, transformar y devolver
    return { content: [{ type: 'text', text: JSON.stringify(result) }] }
  },
}

args llega ya validado contra el inputSchema; extra es contexto que construye el SDK en cada llamada (signal, requestId y authInfo, todavía undefined en este bloque porque no existía la capa de auth). Lo que devuelve el handler tiene cuatro campos posibles:

  • content: lo que leen el modelo y el usuario
  • structuredContent: un objeto que solo tiene sentido si declaraste outputSchema
  • _meta: metadatos de esa ejecución
  • isError

Marcar isError en true no es un crash ni un error 500 (esos ni siquiera llegan hasta aquí): es la herramienta diciendo que se ejecutó pero no pudo satisfacer la petición. Omitirlo convierte «Company group not found» en una respuesta que el modelo lee como exitosa; declararlo, con un texto que apunta a la tool que arregla el problema, deja que el modelo reintente en el mismo turno sin volver a preguntar.

Autenticación OAuth en un servidor MCP: por qué es una capa, no una comprobación repetida

Con las seis tools registradas y descubribles, extra.authInfo seguía siendo undefined: cualquiera con la URL podía leer la liquidez de cualquier empresa. La respuesta no fue meter una comprobación de token en cada handler, sino envolver todo el servidor en un wrapper y añadir tres rutas de descubrimiento, sin tocar ni una línea de las herramientas.

El wrapper y los documentos de descubrimiento

El archivo de ruta ya no exporta el handler de MCP directo, sino uno envuelto:

const handler = withMcpAuth(
  createMcpHandler(registerMcpTools, { serverInfo: { /* … */ } }, { basePath: '/api' }),
  verifyToken,
  { required: true, resourceMetadataPath: '/.well-known/oauth-protected-resource' }
)

export { handler as GET, handler as POST, handler as DELETE }

Sus tres argumentos responden a tres preguntas:

  • Qué proteger: todo lo del bloque anterior, sin tocar.
  • Cómo verificar: la librería entrega el Authorization: Bearer <token> y tú devuelves un AuthInfo, o undefined si no hay nada válido; cómo se valida el token es decisión nuestra, en Sherpa Platform un servicio OAuth propio.
  • Qué reglas aplicar: required: true bloquea toda petición sin auth válida, y resourceMetadataPath es la dirección que el 401 devuelve para que el cliente sepa dónde autenticarse.

Dos documentos completan la capa: /.well-known/oauth-protected-resource declara qué servidor de autorización emite tokens para este recurso y /.well-known/oauth-authorization-server expone las direcciones de registro, autorización, consentimiento y token con las que el cliente resuelve cómo autenticarse.

El flujo completo

El flujo, tal y como lo describe la especificación de autorización de MCP, tiene tres fases y un único paso humano:

  • Empieza con un 401 que actúa como puerta de entrada
  • Sigue con el registro y la autorización con el intercambio de código verificado que exige PKCE
  • Termina operando con Bearer access_token en cada llamada.

El access token dura una hora; el refresh token, 30 días, renovándose en cada uso, de modo que el usuario consiente una sola vez y rara vez vuelve a ver esa pantalla.

Un UUID válido no es un UUID autorizado

Todo el andamiaje anterior existe para que, dentro del handler, quepan tres líneas:

const authExtras = extra.authInfo?.extra
const companyGroup = authExtras.companyGroups.find((group) => group.uuid === companyGroupId)
if (!companyGroup) return accessDeniedError

Leer los grupos de empresa que puede ver el usuario, buscar el companyGroupId que pide el modelo dentro de esa lista y devolver un error de acceso denegado si no aparece. No hay llamada a la base de datos ni confianza de por medio: es un array que viene de un token verificado y un .find().

El modelo puede proponer cualquier companyGroupId con forma de UUID válida (el inputSchema no se lo va a impedir), pero solo el handler decide si ese UUID fue alguna vez del usuario que preguntaba. Esa es la regla que aplicamos a cada tool que añadimos después: describir bien para el modelo, anotar con hints que digan la verdad, devolver errores accionables con isError para que pueda reintentar en el mismo turno y validar siempre contra el token, nunca contra el esquema. Un token válido y una llamada directa a tools/call llegan igual de lejos que el chat mejor cuidado, así que el modelo nunca es el punto de control: lo es el handler.

Todo lo que hace posible que el modelo llame primero a sherpa_list_company_groups sin que nadie se lo pida nace en esta combinación: tools que dicen la verdad sobre lo que hacen y una capa de autenticación que decide, aparte, quién puede pedir qué.

Sigue nuestro viaje construyendo agentes IA desde dentro